Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 288e075786 | |||
| 13c6430dee | |||
| ec3793dd05 | |||
| d5f6ceba19 | |||
| 6f0553d7dd | |||
| 82b2be48cd | |||
| 269a549563 | |||
| 055c0dfe10 |
@@ -195,31 +195,51 @@ gh pr merge --squash --delete-branch
|
|||||||
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
||||||
> Not just auto-generated commit lists, but a brief descriptive text.
|
> 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?
|
**Required structure of release notes:**
|
||||||
2. **Features & Changes**: Brief list of key changes
|
|
||||||
3. **Breaking Changes Warning** (if applicable): See below
|
1. **"What's New"** (1-2 sentences): Brief intro explaining the main change
|
||||||
4. **Optional**: Acknowledgements, documentation links
|
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:**
|
**Example of good release notes:**
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## What's New
|
## 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
|
### New Features
|
||||||
- 🔔 Intake reminder notifications with configurable nagging intervals
|
|
||||||
- 📊 Enhanced stock calculation with blister tracking
|
|
||||||
- 🌐 German translation improvements
|
|
||||||
|
|
||||||
### Bug Fixes
|
- **Medication Refill**: Track when you refill your medications with a single click. Add full packs or individual pills and view complete refill history.
|
||||||
- Fixed timezone handling in dose scheduling
|
- **Automatic Stock Updates**: Stock levels are automatically recalculated after each refill.
|
||||||
- Improved image upload validation
|
- **Refill History**: Each medication shows a complete history of all refills with timestamps.
|
||||||
|
|
||||||
### Full Changelog
|
### Mobile Improvements
|
||||||
[All commits since v1.2.0](link)
|
|
||||||
|
- **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!)
|
### 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.
|
> Users upgrade their Docker containers but keep their existing DB.
|
||||||
> The app must NOT crash if old columns are missing.
|
> 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
|
### Rules for New Columns
|
||||||
|
|
||||||
1. **ALWAYS with DEFAULT value**: New columns must have `NOT NULL DEFAULT <value>`
|
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`
|
2. **NULL-safe in code**: All queries must use `?? defaultValue` or `?? false`
|
||||||
3. **Update schema SQL**: Add to these files:
|
3. **Generate migration**: Run `npx drizzle-kit generate` after schema changes
|
||||||
- `backend/src/db/schema.ts` - Drizzle Schema
|
4. **Add ALTER migration**: For backward compatibility with existing DBs
|
||||||
- `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,
|
|
||||||
```
|
|
||||||
|
|
||||||
### What is NOT Allowed
|
### What is NOT Allowed
|
||||||
|
|
||||||
- ❌ Deleting or renaming columns (breaks old DBs)
|
- ❌ Deleting or renaming columns (breaks old DBs)
|
||||||
- ❌ `NOT NULL` without `DEFAULT` (INSERT fails)
|
- ❌ `NOT NULL` without `DEFAULT` (INSERT fails)
|
||||||
- ❌ Reading columns without fallback in code
|
- ❌ Reading columns without fallback in code
|
||||||
|
- ❌ Manually editing migration SQL files
|
||||||
- ❌ Documenting "delete DB" as a solution
|
- ❌ Documenting "delete DB" as a solution
|
||||||
|
|
||||||
### When Backward Compatibility is NOT Possible
|
### When Backward Compatibility is NOT Possible
|
||||||
@@ -504,6 +534,8 @@ If a breaking change is unavoidable:
|
|||||||
|---------|----------|
|
|---------|----------|
|
||||||
| Backend entry | `backend/src/index.ts` |
|
| Backend entry | `backend/src/index.ts` |
|
||||||
| Database schema | `backend/src/db/schema.ts` |
|
| Database schema | `backend/src/db/schema.ts` |
|
||||||
|
| Drizzle migrations | `backend/drizzle/*.sql` |
|
||||||
|
| Drizzle config | `backend/drizzle.config.ts` |
|
||||||
| Backend routes | `backend/src/routes/*.ts` |
|
| Backend routes | `backend/src/routes/*.ts` |
|
||||||
| Backend services | `backend/src/services/*.ts` |
|
| Backend services | `backend/src/services/*.ts` |
|
||||||
| Frontend app | `frontend/src/App.tsx` |
|
| Frontend app | `frontend/src/App.tsx` |
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ name: Build and Push Docker Images
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'docker-compose*.yml'
|
||||||
|
- '.github/workflows/docker-build.yml'
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -113,6 +118,8 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Create GitHub Release (only on tag push)
|
# Create GitHub Release (only on tag push)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
> **Think of this app as a helpful tool, but make all health decisions independently!**
|
> **Think of this app as a helpful tool, but make all health decisions independently!**
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
|
- [Screenshots](#screenshots)
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
@@ -38,11 +39,91 @@
|
|||||||
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
|
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<a id="screenshots"></a>
|
||||||
|
<details>
|
||||||
|
<summary><strong>Screenshots</strong></summary>
|
||||||
|
<blockquote>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Dashboard</summary>
|
||||||
|
|
||||||
|
Overview with stock status, reorder reminders, and upcoming schedules.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/dashboard-desktop.png" alt="Dashboard" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Medication Detail</summary>
|
||||||
|
|
||||||
|
View medication details, stock information, and intake schedule.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/medication-detail-modal.png" alt="Medication Detail Modal" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Medications & Edit Form</summary>
|
||||||
|
|
||||||
|
Manage your medications with the edit form and refill feature.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/medications-edit-desktop.png" alt="Medications Edit Form" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Demand Calculator (Planner)</summary>
|
||||||
|
|
||||||
|
Calculate how many pills you need for a specific date range.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/planner-desktop.png" alt="Planner - Demand Calculator" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Shared Schedule</summary>
|
||||||
|
|
||||||
|
Share your medication schedule with others via a public link.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/share-schedule-desktop.png" alt="Shared Schedule" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Mobile Views</summary>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<strong>Dashboard</strong><br>
|
||||||
|
<img src="docs/screenshots/dashboard-mobile.png" alt="Mobile Dashboard" width="100%" />
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<strong>Medications</strong><br>
|
||||||
|
<img src="docs/screenshots/medications-mobile.png" alt="Mobile Medications" width="100%" />
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<strong>Schedule</strong><br>
|
||||||
|
<img src="docs/screenshots/schedule-mobile.png" alt="Mobile Schedule" width="100%" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</blockquote>
|
||||||
|
</details>
|
||||||
|
|
||||||
### Smart Inventory
|
### Smart Inventory
|
||||||
- Track exact stock: packs, blisters, and loose pills
|
- Track exact stock: packs, blisters, and loose pills
|
||||||
- Display remaining days of supply
|
- Display remaining days of supply
|
||||||
- Automatic calculation based on intake schedule
|
- Automatic calculation based on intake schedule
|
||||||
|
|
||||||
|
### Medication Refill
|
||||||
|
- One-click refill with pack or loose pill options
|
||||||
|
- Complete refill history per medication
|
||||||
|
- Automatic stock updates after each refill
|
||||||
|
|
||||||
### Flexible Schedules
|
### Flexible Schedules
|
||||||
- Daily, weekly, or custom intervals per medication
|
- Daily, weekly, or custom intervals per medication
|
||||||
- Independent schedules for each medication
|
- Independent schedules for each medication
|
||||||
@@ -60,6 +141,11 @@
|
|||||||
- Manage medications for multiple people
|
- Manage medications for multiple people
|
||||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||||
|
|
||||||
|
### Data Export & Import
|
||||||
|
- Export all your data (medications, dose history, settings) as JSON
|
||||||
|
- Import previously exported data with automatic ID remapping
|
||||||
|
- Choose whether to include sensitive data in exports
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
- Email via SMTP
|
- Email via SMTP
|
||||||
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL || "./data/medassist.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
CREATE TABLE `dose_tracking` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`dose_id` text(255) NOT NULL,
|
||||||
|
`taken_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||||
|
`marked_by` text(100),
|
||||||
|
`dismissed` integer DEFAULT false NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `medications` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`name` text(100) NOT NULL,
|
||||||
|
`generic_name` text(100),
|
||||||
|
`taken_by_json` text DEFAULT '[]' NOT NULL,
|
||||||
|
`pack_count` integer DEFAULT 1 NOT NULL,
|
||||||
|
`blisters_per_pack` integer DEFAULT 1 NOT NULL,
|
||||||
|
`pills_per_blister` integer DEFAULT 1 NOT NULL,
|
||||||
|
`loose_tablets` integer DEFAULT 0 NOT NULL,
|
||||||
|
`pill_weight_mg` integer,
|
||||||
|
`usage_json` text DEFAULT '[]' NOT NULL,
|
||||||
|
`every_json` text DEFAULT '[]' NOT NULL,
|
||||||
|
`start_json` text DEFAULT '[]' NOT NULL,
|
||||||
|
`image_url` text,
|
||||||
|
`expiry_date` text,
|
||||||
|
`notes` text,
|
||||||
|
`intake_reminders_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `refill_history` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`medication_id` integer NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`packs_added` integer DEFAULT 0 NOT NULL,
|
||||||
|
`loose_pills_added` integer DEFAULT 0 NOT NULL,
|
||||||
|
`refill_date` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||||
|
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `refresh_tokens` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`token_id` text(255) NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`rotated_at` integer,
|
||||||
|
`revoked` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `refresh_tokens_token_id_unique` ON `refresh_tokens` (`token_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `share_tokens` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`token` text(64) NOT NULL,
|
||||||
|
`taken_by` text(100) NOT NULL,
|
||||||
|
`schedule_days` integer DEFAULT 30 NOT NULL,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`expires_at` integer,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `share_tokens_token_unique` ON `share_tokens` (`token`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_settings` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`email_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`notification_email` text,
|
||||||
|
`email_stock_reminders` integer DEFAULT true NOT NULL,
|
||||||
|
`email_intake_reminders` integer DEFAULT true NOT NULL,
|
||||||
|
`shoutrrr_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`shoutrrr_url` text,
|
||||||
|
`shoutrrr_stock_reminders` integer DEFAULT true NOT NULL,
|
||||||
|
`shoutrrr_intake_reminders` integer DEFAULT true NOT NULL,
|
||||||
|
`reminder_days_before` integer DEFAULT 7 NOT NULL,
|
||||||
|
`repeat_daily_reminders` integer DEFAULT false NOT NULL,
|
||||||
|
`skip_reminders_for_taken_doses` integer DEFAULT false NOT NULL,
|
||||||
|
`repeat_reminders_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`reminder_repeat_interval_minutes` integer DEFAULT 30 NOT NULL,
|
||||||
|
`max_nagging_reminders` integer DEFAULT 5 NOT NULL,
|
||||||
|
`low_stock_days` integer DEFAULT 30 NOT NULL,
|
||||||
|
`normal_stock_days` integer DEFAULT 90 NOT NULL,
|
||||||
|
`high_stock_days` integer DEFAULT 180 NOT NULL,
|
||||||
|
`expiry_warning_days` integer DEFAULT 90 NOT NULL,
|
||||||
|
`language` text(10) DEFAULT 'en' NOT NULL,
|
||||||
|
`stock_calculation_mode` text(20) DEFAULT 'automatic' NOT NULL,
|
||||||
|
`last_auto_email_sent` text,
|
||||||
|
`last_notification_type` text,
|
||||||
|
`last_notification_channel` text,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_settings_user_id_unique` ON `user_settings` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`username` text(100) NOT NULL,
|
||||||
|
`password_hash` text(255),
|
||||||
|
`avatar_url` text(255),
|
||||||
|
`auth_provider` text(50) DEFAULT 'local' NOT NULL,
|
||||||
|
`oidc_subject` text(255),
|
||||||
|
`is_active` integer DEFAULT true NOT NULL,
|
||||||
|
`last_login_at` integer,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"dose_tracking": {
|
||||||
|
"name": "dose_tracking",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dose_id": {
|
||||||
|
"name": "dose_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_at": {
|
||||||
|
"name": "taken_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
},
|
||||||
|
"marked_by": {
|
||||||
|
"name": "marked_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"name": "dismissed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dose_tracking_user_id_users_id_fk": {
|
||||||
|
"name": "dose_tracking_user_id_users_id_fk",
|
||||||
|
"tableFrom": "dose_tracking",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"medications": {
|
||||||
|
"name": "medications",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generic_name": {
|
||||||
|
"name": "generic_name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by_json": {
|
||||||
|
"name": "taken_by_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"pack_count": {
|
||||||
|
"name": "pack_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"blisters_per_pack": {
|
||||||
|
"name": "blisters_per_pack",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"pills_per_blister": {
|
||||||
|
"name": "pills_per_blister",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"loose_tablets": {
|
||||||
|
"name": "loose_tablets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"pill_weight_mg": {
|
||||||
|
"name": "pill_weight_mg",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"usage_json": {
|
||||||
|
"name": "usage_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"every_json": {
|
||||||
|
"name": "every_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"start_json": {
|
||||||
|
"name": "start_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"name": "expiry_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"intake_reminders_enabled": {
|
||||||
|
"name": "intake_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"medications_user_id_users_id_fk": {
|
||||||
|
"name": "medications_user_id_users_id_fk",
|
||||||
|
"tableFrom": "medications",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refill_history": {
|
||||||
|
"name": "refill_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"medication_id": {
|
||||||
|
"name": "medication_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"packs_added": {
|
||||||
|
"name": "packs_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"loose_pills_added": {
|
||||||
|
"name": "loose_pills_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"refill_date": {
|
||||||
|
"name": "refill_date",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refill_history_medication_id_medications_id_fk": {
|
||||||
|
"name": "refill_history_medication_id_medications_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "medications",
|
||||||
|
"columnsFrom": [
|
||||||
|
"medication_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"refill_history_user_id_users_id_fk": {
|
||||||
|
"name": "refill_history_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_id": {
|
||||||
|
"name": "token_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rotated_at": {
|
||||||
|
"name": "rotated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revoked": {
|
||||||
|
"name": "revoked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_id_unique": {
|
||||||
|
"name": "refresh_tokens_token_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"token_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"share_tokens": {
|
||||||
|
"name": "share_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by": {
|
||||||
|
"name": "taken_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_days": {
|
||||||
|
"name": "schedule_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"share_tokens_token_unique": {
|
||||||
|
"name": "share_tokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "share_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "share_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"name": "user_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"name": "email_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notification_email": {
|
||||||
|
"name": "notification_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_stock_reminders": {
|
||||||
|
"name": "email_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_intake_reminders": {
|
||||||
|
"name": "email_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_enabled": {
|
||||||
|
"name": "shoutrrr_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"shoutrrr_url": {
|
||||||
|
"name": "shoutrrr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"shoutrrr_stock_reminders": {
|
||||||
|
"name": "shoutrrr_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_intake_reminders": {
|
||||||
|
"name": "shoutrrr_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"reminder_days_before": {
|
||||||
|
"name": "reminder_days_before",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 7
|
||||||
|
},
|
||||||
|
"repeat_daily_reminders": {
|
||||||
|
"name": "repeat_daily_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"skip_reminders_for_taken_doses": {
|
||||||
|
"name": "skip_reminders_for_taken_doses",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"repeat_reminders_enabled": {
|
||||||
|
"name": "repeat_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"reminder_repeat_interval_minutes": {
|
||||||
|
"name": "reminder_repeat_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"max_nagging_reminders": {
|
||||||
|
"name": "max_nagging_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"low_stock_days": {
|
||||||
|
"name": "low_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"normal_stock_days": {
|
||||||
|
"name": "normal_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"high_stock_days": {
|
||||||
|
"name": "high_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 180
|
||||||
|
},
|
||||||
|
"expiry_warning_days": {
|
||||||
|
"name": "expiry_warning_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"stock_calculation_mode": {
|
||||||
|
"name": "stock_calculation_mode",
|
||||||
|
"type": "text(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'automatic'"
|
||||||
|
},
|
||||||
|
"last_auto_email_sent": {
|
||||||
|
"name": "last_auto_email_sent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_type": {
|
||||||
|
"name": "last_notification_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_channel": {
|
||||||
|
"name": "last_notification_channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_settings_user_id_unique": {
|
||||||
|
"name": "user_settings_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_settings_user_id_users_id_fk": {
|
||||||
|
"name": "user_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"oidc_subject": {
|
||||||
|
"name": "oidc_subject",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768600500759,
|
||||||
|
"tag": "0000_init",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"@libsql/client": "^0.10.0",
|
"@libsql/client": "^0.10.0",
|
||||||
"argon2": "^0.40.0",
|
"argon2": "^0.40.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.32.2",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
"openid-client": "^6.8.1",
|
"openid-client": "^6.8.1",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"@types/nodemailer": "^6.4.21",
|
"@types/nodemailer": "^6.4.21",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { createClient, Client } from "@libsql/client";
|
import { createClient, Client } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { getTableCreationSQL } from "./schema-sql.js";
|
|
||||||
|
|
||||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||||
|
|
||||||
|
// Get migrations folder path (relative to this file's location)
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Exported utility functions for testing
|
// Exported utility functions for testing
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -44,23 +50,20 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
|
/** Run drizzle-kit migrations on the database */
|
||||||
export { getTableCreationSQL } from "./schema-sql.js";
|
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await migrate(database, { migrationsFolder });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Run table creation migrations on a client */
|
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||||
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||||
const tableCreations = getTableCreationSQL();
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
for (const sql of tableCreations) {
|
|
||||||
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)
|
// These add new columns to existing tables (silently fail if column already exists)
|
||||||
const alterMigrations = [
|
const alterMigrations = [
|
||||||
// Added in v1.x - repeat reminders and nagging settings
|
// 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 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 reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
`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) {
|
for (const sql of alterMigrations) {
|
||||||
@@ -147,9 +152,19 @@ export const db = drizzle(client);
|
|||||||
|
|
||||||
// Auto-run migrations (self-healing database)
|
// Auto-run migrations (self-healing database)
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
const result = await runTableMigrations(client);
|
// Run drizzle-kit generated migrations
|
||||||
if (result.errors.length > 0) {
|
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
||||||
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
|
const migrateResult = await runDrizzleMigrations(db);
|
||||||
|
if (!migrateResult.success) {
|
||||||
|
console.error(`[DB] Migration error:`, migrateResult.error);
|
||||||
|
} else {
|
||||||
|
console.log(`[DB] Drizzle migrations completed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run ALTER TABLE migrations for backward compatibility
|
||||||
|
const alterResult = await runAlterMigrations(client);
|
||||||
|
if (alterResult.errors.length > 0) {
|
||||||
|
alterResult.errors.forEach(err => console.error(`[DB] ALTER migration error:`, err));
|
||||||
}
|
}
|
||||||
console.log(`[DB] Tables verified/created`);
|
console.log(`[DB] Tables verified/created`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,45 @@
|
|||||||
import { createClient, Client } from "@libsql/client";
|
import { createClient, Client } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import fs from "fs";
|
import { resolve, dirname } from "path";
|
||||||
import path from "path";
|
import { fileURLToPath } from "url";
|
||||||
import { getTableCreationSQL } from "./schema-sql.js";
|
|
||||||
|
|
||||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||||
|
|
||||||
|
// Get migrations folder path (relative to this file's location)
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Exported utility functions for testing
|
// Exported utility functions for testing
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/** Get the full migration SQL string (re-exported from schema-sql) */
|
/** Split SQL string into individual statements (for backwards compatibility with tests) */
|
||||||
export { getTableCreationSQL };
|
|
||||||
|
|
||||||
/** Split SQL string into individual statements */
|
|
||||||
export function splitSQLStatements(sql: string): string[] {
|
export function splitSQLStatements(sql: string): string[] {
|
||||||
return sql.split(';').filter(s => s.trim().length > 0);
|
return sql.split(';').filter(s => s.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Execute migration statements on a client */
|
/** Execute drizzle migrations on a database */
|
||||||
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let executed = 0;
|
const db = drizzle(client);
|
||||||
|
|
||||||
for (const stmt of statements) {
|
try {
|
||||||
try {
|
await migrate(db, { migrationsFolder });
|
||||||
await client.execute(stmt);
|
|
||||||
executed++;
|
// Count tables as a proxy for "executed" statements
|
||||||
} catch (err: any) {
|
const tables = await client.execute(
|
||||||
errors.push(err.message);
|
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
|
||||||
}
|
);
|
||||||
|
const executed = Number(tables.rows[0].count) || 0;
|
||||||
|
|
||||||
|
return { success: true, executed, errors };
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(err.message);
|
||||||
|
return { success: false, executed: 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: errors.length === 0, executed, errors };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a preview of statement (first N characters) */
|
/** Get a preview of statement (first N characters) */
|
||||||
@@ -54,15 +60,13 @@ const url = "file:./data/medassist-ng.db";
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log("Starting database setup...");
|
console.log("Starting database setup...");
|
||||||
console.log("Database URL:", url);
|
console.log("Database URL:", url);
|
||||||
|
console.log("Migrations folder:", migrationsFolder);
|
||||||
|
|
||||||
const client = createClient({ url });
|
const client = createClient({ url });
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
const statements = getTableCreationSQL();
|
console.log("Running drizzle migrations...");
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
for (const stmt of statements) {
|
|
||||||
console.log("Executing:", getStatementPreview(stmt));
|
|
||||||
await client.execute(stmt);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Database setup complete!");
|
console.log("Database setup complete!");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ export function getTableCreationSQL(): string[] {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
medication_id integer NOT NULL,
|
||||||
|
user_id integer NOT NULL,
|
||||||
|
packs_added integer NOT NULL DEFAULT 0,
|
||||||
|
loose_pills_added integer NOT NULL DEFAULT 0,
|
||||||
|
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
lowStockDays: integer("low_stock_days").notNull().default(30),
|
lowStockDays: integer("low_stock_days").notNull().default(30),
|
||||||
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
||||||
highStockDays: integer("high_stock_days").notNull().default(180),
|
highStockDays: integer("high_stock_days").notNull().default(180),
|
||||||
|
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: text("language", { length: 10 }).notNull().default("en"),
|
language: text("language", { length: 10 }).notNull().default("en"),
|
||||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||||
@@ -115,4 +116,17 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||||
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Refill History - Tracks when medication stock was refilled
|
||||||
|
// =============================================================================
|
||||||
|
export const refillHistory = sqliteTable("refill_history", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
medicationId: integer("medication_id").notNull().references(() => medications.id, { onDelete: "cascade" }),
|
||||||
|
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
packsAdded: integer("packs_added").notNull().default(0),
|
||||||
|
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
|
||||||
|
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { plannerRoutes } from "./routes/planner.js";
|
|||||||
import { shareRoutes } from "./routes/share.js";
|
import { shareRoutes } from "./routes/share.js";
|
||||||
import { doseRoutes } from "./routes/doses.js";
|
import { doseRoutes } from "./routes/doses.js";
|
||||||
import { exportRoutes } from "./routes/export.js";
|
import { exportRoutes } from "./routes/export.js";
|
||||||
|
import { refillRoutes } from "./routes/refills.js";
|
||||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ export async function createApp(options?: {
|
|||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -184,6 +186,7 @@ await app.register(plannerRoutes);
|
|||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
@@ -18,6 +18,10 @@ const shareDoseSchema = z.object({
|
|||||||
doseId: z.string().min(1, "doseId is required"),
|
doseId: z.string().min(1, "doseId is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dismissDosesSchema = z.object({
|
||||||
|
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||||
|
});
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: any, reply: any): Promise<number> {
|
async function getUserId(request: any, reply: any): Promise<number> {
|
||||||
@@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -127,6 +132,103 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
||||||
|
"/doses/dismiss",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
|
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { doseIds } = parsed.data;
|
||||||
|
|
||||||
|
// Insert dismissed records for each dose that doesn't exist yet
|
||||||
|
let dismissedCount = 0;
|
||||||
|
for (const doseId of doseIds) {
|
||||||
|
// Check if already exists (taken or dismissed)
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, userId),
|
||||||
|
eq(doseTracking.doseId, doseId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Already exists - update to dismissed if not already
|
||||||
|
if (!existing.dismissed) {
|
||||||
|
await db.update(doseTracking)
|
||||||
|
.set({ dismissed: true })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, userId),
|
||||||
|
eq(doseTracking.doseId, doseId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new dismissed record
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId,
|
||||||
|
doseId,
|
||||||
|
markedBy: null,
|
||||||
|
dismissed: true,
|
||||||
|
});
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, dismissedCount };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.delete(
|
||||||
|
"/doses/dismiss",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
|
// Delete all dismissed-only records (not taken ones)
|
||||||
|
// For taken+dismissed, just remove the dismissed flag
|
||||||
|
const dismissed = await db.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, userId),
|
||||||
|
eq(doseTracking.dismissed, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const d of dismissed) {
|
||||||
|
if (d.markedBy !== null || d.takenAt) {
|
||||||
|
// This was also marked as taken - just remove dismissed flag
|
||||||
|
await db.update(doseTracking)
|
||||||
|
.set({ dismissed: false })
|
||||||
|
.where(eq(doseTracking.id, d.id));
|
||||||
|
} else {
|
||||||
|
// This was only dismissed - delete it
|
||||||
|
await db.delete(doseTracking)
|
||||||
|
.where(eq(doseTracking.id, d.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, clearedCount: dismissed.length };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,6 +253,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { medications, refillHistory } from "../db/schema.js";
|
||||||
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
|
||||||
|
const refillSchema = z.object({
|
||||||
|
packsAdded: z.number().int().min(0).default(0),
|
||||||
|
loosePillsAdded: z.number().int().min(0).default(0),
|
||||||
|
}).refine(data => data.packsAdded > 0 || data.loosePillsAdded > 0, {
|
||||||
|
message: "Must add at least one pack or some loose pills",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function refillRoutes(app: FastifyInstance) {
|
||||||
|
// All refill routes require auth
|
||||||
|
app.addHook("preHandler", requireAuth);
|
||||||
|
|
||||||
|
// Helper to get user ID from request
|
||||||
|
async function getUserId(request: any, reply: any): Promise<number> {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return getAnonymousUserId();
|
||||||
|
}
|
||||||
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
|
if (!authUser) {
|
||||||
|
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||||
|
throw new Error("AUTH_REQUIRED");
|
||||||
|
}
|
||||||
|
return authUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /medications/:id/refill - Add stock to medication
|
||||||
|
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
|
||||||
|
const parsed = refillSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
|
const medId = Number(req.params.id);
|
||||||
|
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const [med] = await db.select().from(medications).where(
|
||||||
|
and(eq(medications.id, medId), eq(medications.userId, userId))
|
||||||
|
);
|
||||||
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
|
const { packsAdded, loosePillsAdded } = parsed.data;
|
||||||
|
|
||||||
|
// Update medication stock
|
||||||
|
const newPackCount = med.packCount + packsAdded;
|
||||||
|
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
||||||
|
|
||||||
|
await db.update(medications)
|
||||||
|
.set({
|
||||||
|
packCount: newPackCount,
|
||||||
|
looseTablets: newLooseTablets,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||||
|
|
||||||
|
// Create refill history entry
|
||||||
|
const [refill] = await db.insert(refillHistory)
|
||||||
|
.values({
|
||||||
|
medicationId: medId,
|
||||||
|
userId,
|
||||||
|
packsAdded,
|
||||||
|
loosePillsAdded,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Calculate pills added for response
|
||||||
|
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
|
||||||
|
const totalPillsAdded = (packsAdded * pillsPerPack) + loosePillsAdded;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
refill: {
|
||||||
|
id: refill.id,
|
||||||
|
packsAdded,
|
||||||
|
loosePillsAdded,
|
||||||
|
totalPillsAdded,
|
||||||
|
refillDate: refill.refillDate,
|
||||||
|
},
|
||||||
|
newStock: {
|
||||||
|
packCount: newPackCount,
|
||||||
|
looseTablets: newLooseTablets,
|
||||||
|
totalPills: newPackCount * pillsPerPack + newLooseTablets,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /medications/:id/refills - Get refill history for a medication
|
||||||
|
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
|
||||||
|
const medId = Number(req.params.id);
|
||||||
|
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const [med] = await db.select().from(medications).where(
|
||||||
|
and(eq(medications.id, medId), eq(medications.userId, userId))
|
||||||
|
);
|
||||||
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
|
// Get refill history, newest first
|
||||||
|
const refills = await db.select()
|
||||||
|
.from(refillHistory)
|
||||||
|
.where(eq(refillHistory.medicationId, medId))
|
||||||
|
.orderBy(desc(refillHistory.refillDate));
|
||||||
|
|
||||||
|
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
|
||||||
|
|
||||||
|
return refills.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
packsAdded: r.packsAdded,
|
||||||
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
totalPillsAdded: (r.packsAdded * pillsPerPack) + r.loosePillsAdded,
|
||||||
|
refillDate: r.refillDate,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,45 +1,78 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { createClient } from "@libsql/client";
|
import { createClient } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { mkdirSync, rmSync, existsSync } from "fs";
|
import { mkdirSync, rmSync, existsSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve, dirname } from "path";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// Import the exported utility functions from client.ts
|
// Import the exported utility functions from client.ts
|
||||||
import {
|
import {
|
||||||
buildDbUrl,
|
buildDbUrl,
|
||||||
getDbPaths,
|
getDbPaths,
|
||||||
ensureDataDirectory,
|
ensureDataDirectory,
|
||||||
getTableCreationSQL,
|
runDrizzleMigrations,
|
||||||
runTableMigrations,
|
runAlterMigrations,
|
||||||
ensureDefaultUser,
|
ensureDefaultUser,
|
||||||
} from "../db/client.js";
|
} from "../db/client.js";
|
||||||
|
|
||||||
// Import the exported utility functions from migrate.ts
|
// Import the exported utility functions from migrate.ts
|
||||||
import {
|
import {
|
||||||
getTableCreationSQL as getTableCreationSQLFromMigrate,
|
|
||||||
splitSQLStatements,
|
splitSQLStatements,
|
||||||
executeMigration,
|
executeMigration,
|
||||||
getStatementPreview,
|
getStatementPreview,
|
||||||
} from "../db/migrate.js";
|
} from "../db/migrate.js";
|
||||||
|
|
||||||
|
// Get migrations folder path
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
describe("Migration Script Utilities", () => {
|
describe("Migration Script Utilities", () => {
|
||||||
describe("getTableCreationSQL", () => {
|
describe("executeMigration", () => {
|
||||||
it("should return a non-empty array of SQL statements", () => {
|
let client: ReturnType<typeof createClient>;
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
expect(Array.isArray(statements)).toBe(true);
|
beforeEach(() => {
|
||||||
expect(statements.length).toBeGreaterThan(0);
|
client = createClient({ url: ":memory:" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should contain all table definitions", () => {
|
it("should execute all migrations successfully", async () => {
|
||||||
const statements = getTableCreationSQL();
|
const result = await executeMigration(client);
|
||||||
const allSQL = statements.join(" ");
|
expect(result.success).toBe(true);
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users");
|
expect(result.executed).toBeGreaterThan(0);
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications");
|
expect(result.errors).toHaveLength(0);
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS user_settings");
|
});
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens");
|
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS share_tokens");
|
it("should create all tables", async () => {
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking");
|
await executeMigration(client);
|
||||||
|
|
||||||
|
const tables = await client.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableNames = tables.rows.map(r => r.name);
|
||||||
|
expect(tableNames).toContain("users");
|
||||||
|
expect(tableNames).toContain("medications");
|
||||||
|
expect(tableNames).toContain("user_settings");
|
||||||
|
expect(tableNames).toContain("refresh_tokens");
|
||||||
|
expect(tableNames).toContain("share_tokens");
|
||||||
|
expect(tableNames).toContain("dose_tracking");
|
||||||
|
expect(tableNames).toContain("refill_history");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be idempotent", async () => {
|
||||||
|
await executeMigration(client);
|
||||||
|
const result = await executeMigration(client);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow inserting data after migration", async () => {
|
||||||
|
await executeMigration(client);
|
||||||
|
|
||||||
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
const result = await client.execute("SELECT * FROM users");
|
||||||
|
expect(result.rows).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,11 +95,6 @@ describe("Migration Script Utilities", () => {
|
|||||||
expect(statements).toHaveLength(2);
|
expect(statements).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle getTableCreationSQL output correctly", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
expect(statements).toHaveLength(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve whitespace within statements", () => {
|
it("should preserve whitespace within statements", () => {
|
||||||
const sql = "CREATE TABLE test (\n id INTEGER\n);";
|
const sql = "CREATE TABLE test (\n id INTEGER\n);";
|
||||||
const statements = splitSQLStatements(sql);
|
const statements = splitSQLStatements(sql);
|
||||||
@@ -103,52 +131,6 @@ describe("Migration Script Utilities", () => {
|
|||||||
expect(preview).toBe("CREATE TABLE IF NOT EXISTS use...");
|
expect(preview).toBe("CREATE TABLE IF NOT EXISTS use...");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("executeMigration", () => {
|
|
||||||
let client: ReturnType<typeof createClient>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
client = createClient({ url: ":memory:" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should execute all migrations successfully", async () => {
|
|
||||||
const result = await executeMigration(client);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.executed).toBe(6);
|
|
||||||
expect(result.errors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create all tables", async () => {
|
|
||||||
await executeMigration(client);
|
|
||||||
|
|
||||||
const tables = await client.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
||||||
);
|
|
||||||
|
|
||||||
const tableNames = tables.rows.map(r => r.name);
|
|
||||||
expect(tableNames).toContain("users");
|
|
||||||
expect(tableNames).toContain("medications");
|
|
||||||
expect(tableNames).toContain("user_settings");
|
|
||||||
expect(tableNames).toContain("refresh_tokens");
|
|
||||||
expect(tableNames).toContain("share_tokens");
|
|
||||||
expect(tableNames).toContain("dose_tracking");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be idempotent", async () => {
|
|
||||||
await executeMigration(client);
|
|
||||||
const result = await executeMigration(client);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.executed).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow inserting data after migration", async () => {
|
|
||||||
await executeMigration(client);
|
|
||||||
|
|
||||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
|
||||||
const result = await client.execute("SELECT * FROM users");
|
|
||||||
expect(result.rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Database Client Utilities", () => {
|
describe("Database Client Utilities", () => {
|
||||||
@@ -218,63 +200,7 @@ describe("Database Client Utilities", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getTableCreationSQL", () => {
|
describe("runDrizzleMigrations", () => {
|
||||||
it("should return array of SQL statements", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
expect(Array.isArray(statements)).toBe(true);
|
|
||||||
expect(statements.length).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include users table", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
const usersSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS users"));
|
|
||||||
expect(usersSQL).toBeDefined();
|
|
||||||
expect(usersSQL).toContain("username text NOT NULL UNIQUE");
|
|
||||||
expect(usersSQL).toContain("password_hash text");
|
|
||||||
expect(usersSQL).toContain("auth_provider text NOT NULL DEFAULT 'local'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include medications table", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
const medsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS medications"));
|
|
||||||
expect(medsSQL).toBeDefined();
|
|
||||||
expect(medsSQL).toContain("user_id integer NOT NULL");
|
|
||||||
expect(medsSQL).toContain("taken_by_json text NOT NULL DEFAULT '[]'");
|
|
||||||
expect(medsSQL).toContain("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include user_settings table", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
const settingsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS user_settings"));
|
|
||||||
expect(settingsSQL).toBeDefined();
|
|
||||||
expect(settingsSQL).toContain("email_enabled integer NOT NULL DEFAULT 0");
|
|
||||||
expect(settingsSQL).toContain("language text NOT NULL DEFAULT 'en'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include refresh_tokens table", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
const tokensSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS refresh_tokens"));
|
|
||||||
expect(tokensSQL).toBeDefined();
|
|
||||||
expect(tokensSQL).toContain("token_id text NOT NULL UNIQUE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include share_tokens table", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
const shareSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS share_tokens"));
|
|
||||||
expect(shareSQL).toBeDefined();
|
|
||||||
expect(shareSQL).toContain("taken_by text NOT NULL");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include dose_tracking table", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
const doseSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS dose_tracking"));
|
|
||||||
expect(doseSQL).toBeDefined();
|
|
||||||
expect(doseSQL).toContain("dose_id text NOT NULL");
|
|
||||||
expect(doseSQL).toContain("marked_by text");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("runTableMigrations", () => {
|
|
||||||
let client: ReturnType<typeof createClient>;
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -282,23 +208,24 @@ describe("Database Client Utilities", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should create all tables successfully", async () => {
|
it("should create all tables successfully", async () => {
|
||||||
const result = await runTableMigrations(client);
|
const db = drizzle(client);
|
||||||
|
const result = await runDrizzleMigrations(db);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.errors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be idempotent (run twice without errors)", async () => {
|
it("should be idempotent (run twice without errors)", async () => {
|
||||||
await runTableMigrations(client);
|
const db = drizzle(client);
|
||||||
const result = await runTableMigrations(client);
|
await runDrizzleMigrations(db);
|
||||||
|
const result = await runDrizzleMigrations(db);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.errors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create all 6 tables", async () => {
|
it("should create all 7 tables", async () => {
|
||||||
await runTableMigrations(client);
|
const db = drizzle(client);
|
||||||
|
await runDrizzleMigrations(db);
|
||||||
|
|
||||||
const tables = await client.execute(
|
const tables = await client.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableNames = tables.rows.map(r => r.name);
|
const tableNames = tables.rows.map(r => r.name);
|
||||||
@@ -308,6 +235,29 @@ describe("Database Client Utilities", () => {
|
|||||||
expect(tableNames).toContain("refresh_tokens");
|
expect(tableNames).toContain("refresh_tokens");
|
||||||
expect(tableNames).toContain("share_tokens");
|
expect(tableNames).toContain("share_tokens");
|
||||||
expect(tableNames).toContain("dose_tracking");
|
expect(tableNames).toContain("dose_tracking");
|
||||||
|
expect(tableNames).toContain("refill_history");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runAlterMigrations", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should run without errors on a fresh database", async () => {
|
||||||
|
const result = await runAlterMigrations(client);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be idempotent", async () => {
|
||||||
|
await runAlterMigrations(client);
|
||||||
|
const result = await runAlterMigrations(client);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -316,7 +266,8 @@ describe("Database Client Utilities", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await runTableMigrations(client);
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create default user when auth is disabled", async () => {
|
it("should create default user when auth is disabled", async () => {
|
||||||
@@ -386,246 +337,83 @@ describe("Database Client", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Table Schema Creation", () => {
|
describe("Table Schema via Drizzle Migrations", () => {
|
||||||
let client: ReturnType<typeof createClient>;
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create users table", async () => {
|
it("should have users table with correct columns", async () => {
|
||||||
await client.execute(`
|
const columns = await client.execute("PRAGMA table_info(users)");
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
password_hash text,
|
|
||||||
avatar_url text,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local',
|
|
||||||
oidc_subject text,
|
|
||||||
is_active integer NOT NULL DEFAULT 1,
|
|
||||||
last_login_at integer,
|
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Verify table exists
|
|
||||||
const tables = await client.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
|
||||||
);
|
|
||||||
expect(tables.rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create medications table with foreign key", async () => {
|
|
||||||
// First create users table
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS medications (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
generic_name text,
|
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
|
||||||
loose_tablets integer NOT NULL DEFAULT 0,
|
|
||||||
pill_weight_mg integer,
|
|
||||||
usage_json text NOT NULL DEFAULT '[]',
|
|
||||||
every_json text NOT NULL DEFAULT '[]',
|
|
||||||
start_json text NOT NULL DEFAULT '[]',
|
|
||||||
image_url text,
|
|
||||||
expiry_date text,
|
|
||||||
notes text,
|
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
|
||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const tables = await client.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='medications'"
|
|
||||||
);
|
|
||||||
expect(tables.rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create user_settings table", async () => {
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS user_settings (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL UNIQUE,
|
|
||||||
email_enabled integer NOT NULL DEFAULT 0,
|
|
||||||
notification_email text,
|
|
||||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
|
||||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
|
||||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
|
||||||
shoutrrr_url text,
|
|
||||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
|
||||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
|
||||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
|
||||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
|
||||||
low_stock_days integer NOT NULL DEFAULT 30,
|
|
||||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
|
||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
|
||||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
|
||||||
language text NOT NULL DEFAULT 'en',
|
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
|
||||||
last_auto_email_sent text,
|
|
||||||
last_notification_type text,
|
|
||||||
last_notification_channel text,
|
|
||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const tables = await client.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'"
|
|
||||||
);
|
|
||||||
expect(tables.rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create refresh_tokens table", async () => {
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL,
|
|
||||||
token_id text NOT NULL UNIQUE,
|
|
||||||
expires_at integer NOT NULL,
|
|
||||||
rotated_at integer,
|
|
||||||
revoked integer NOT NULL DEFAULT 0,
|
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const tables = await client.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
|
|
||||||
);
|
|
||||||
expect(tables.rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create share_tokens table", async () => {
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS share_tokens (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL,
|
|
||||||
token text NOT NULL UNIQUE,
|
|
||||||
taken_by text NOT NULL,
|
|
||||||
schedule_days integer NOT NULL DEFAULT 30,
|
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
expires_at integer,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const tables = await client.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='share_tokens'"
|
|
||||||
);
|
|
||||||
expect(tables.rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create dose_tracking table", async () => {
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS dose_tracking (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL,
|
|
||||||
dose_id text NOT NULL,
|
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
marked_by text,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const tables = await client.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='dose_tracking'"
|
|
||||||
);
|
|
||||||
expect(tables.rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enforce unique constraint on username", async () => {
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
|
||||||
|
|
||||||
await expect(
|
expect(columnNames).toContain("id");
|
||||||
client.execute("INSERT INTO users (username) VALUES ('testuser')")
|
expect(columnNames).toContain("username");
|
||||||
).rejects.toThrow();
|
expect(columnNames).toContain("password_hash");
|
||||||
|
expect(columnNames).toContain("auth_provider");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enforce unique constraint on refresh token_id", async () => {
|
it("should have medications table with correct columns", async () => {
|
||||||
await client.execute(`
|
const columns = await client.execute("PRAGMA table_info(medications)");
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
|
||||||
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL,
|
|
||||||
token_id text NOT NULL UNIQUE,
|
|
||||||
expires_at integer NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.execute(
|
|
||||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
expect(columnNames).toContain("id");
|
||||||
client.execute(
|
expect(columnNames).toContain("user_id");
|
||||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
expect(columnNames).toContain("name");
|
||||||
)
|
expect(columnNames).toContain("taken_by_json");
|
||||||
).rejects.toThrow();
|
expect(columnNames).toContain("pack_count");
|
||||||
|
expect(columnNames).toContain("usage_json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have user_settings table with correct columns", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(user_settings)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("user_id");
|
||||||
|
expect(columnNames).toContain("email_enabled");
|
||||||
|
expect(columnNames).toContain("language");
|
||||||
|
expect(columnNames).toContain("stock_calculation_mode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have refresh_tokens table", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(refresh_tokens)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("user_id");
|
||||||
|
expect(columnNames).toContain("token_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have share_tokens table", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(share_tokens)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("token");
|
||||||
|
expect(columnNames).toContain("taken_by");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have dose_tracking table", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(dose_tracking)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("dose_id");
|
||||||
|
expect(columnNames).toContain("marked_by");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have refill_history table", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(refill_history)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("medication_id");
|
||||||
|
expect(columnNames).toContain("packs_added");
|
||||||
|
expect(columnNames).toContain("loose_pills_added");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -634,15 +422,8 @@ describe("Database Client", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await client.execute(`
|
const db = drizzle(client);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await migrate(db, { migrationsFolder });
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local',
|
|
||||||
is_active integer NOT NULL DEFAULT 1,
|
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default values for auth_provider", async () => {
|
it("should use default values for auth_provider", async () => {
|
||||||
@@ -656,16 +437,8 @@ describe("Database Client", () => {
|
|||||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
|
||||||
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
|
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
|
||||||
expect(result.rows[0].is_active).toBe(1);
|
// SQLite stores booleans as integers
|
||||||
});
|
expect(result.rows[0].is_active).toBeTruthy();
|
||||||
|
|
||||||
it("should generate created_at timestamp", async () => {
|
|
||||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
|
||||||
|
|
||||||
const result = await client.execute("SELECT created_at FROM users WHERE username = 'testuser'");
|
|
||||||
expect(typeof result.rows[0].created_at).toBe("number");
|
|
||||||
// Should be a reasonable Unix timestamp (after year 2020)
|
|
||||||
expect(Number(result.rows[0].created_at)).toBeGreaterThan(1577836800);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -674,40 +447,18 @@ describe("Database Client", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await client.execute(`
|
const db = drizzle(client);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await migrate(db, { migrationsFolder });
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS user_settings (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL UNIQUE,
|
|
||||||
email_enabled integer NOT NULL DEFAULT 0,
|
|
||||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
|
||||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
|
||||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
|
||||||
low_stock_days integer NOT NULL DEFAULT 30,
|
|
||||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
|
||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
|
||||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
|
||||||
language text NOT NULL DEFAULT 'en',
|
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default notification settings", async () => {
|
it("should use default notification settings", async () => {
|
||||||
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
||||||
|
|
||||||
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
|
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
|
||||||
expect(result.rows[0].email_enabled).toBe(0);
|
// SQLite stores booleans as integers (false = 0)
|
||||||
expect(result.rows[0].shoutrrr_enabled).toBe(0);
|
expect(result.rows[0].email_enabled).toBeFalsy();
|
||||||
|
expect(result.rows[0].shoutrrr_enabled).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default stock threshold settings", async () => {
|
it("should use default stock threshold settings", async () => {
|
||||||
@@ -717,7 +468,6 @@ describe("Database Client", () => {
|
|||||||
expect(result.rows[0].low_stock_days).toBe(30);
|
expect(result.rows[0].low_stock_days).toBe(30);
|
||||||
expect(result.rows[0].normal_stock_days).toBe(90);
|
expect(result.rows[0].normal_stock_days).toBe(90);
|
||||||
expect(result.rows[0].high_stock_days).toBe(180);
|
expect(result.rows[0].high_stock_days).toBe(180);
|
||||||
expect(result.rows[0].expiry_warning_days).toBe(90);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default language (en)", async () => {
|
it("should use default language (en)", async () => {
|
||||||
@@ -747,32 +497,9 @@ describe("Database Client", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await client.execute(`
|
const db = drizzle(client);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await migrate(db, { migrationsFolder });
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS medications (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
|
||||||
loose_tablets integer NOT NULL DEFAULT 0,
|
|
||||||
usage_json text NOT NULL DEFAULT '[]',
|
|
||||||
every_json text NOT NULL DEFAULT '[]',
|
|
||||||
start_json text NOT NULL DEFAULT '[]',
|
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default inventory values", async () => {
|
it("should use default inventory values", async () => {
|
||||||
@@ -795,11 +522,11 @@ describe("Database Client", () => {
|
|||||||
expect(result.rows[0].start_json).toBe("[]");
|
expect(result.rows[0].start_json).toBe("[]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should default intake_reminders_enabled to false (0)", async () => {
|
it("should default intake_reminders_enabled to false", async () => {
|
||||||
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
|
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
|
||||||
|
|
||||||
const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'");
|
const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'");
|
||||||
expect(result.rows[0].intake_reminders_enabled).toBe(0);
|
expect(result.rows[0].intake_reminders_enabled).toBeFalsy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -810,21 +537,8 @@ describe("Database Client", () => {
|
|||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
// Enable foreign keys
|
// Enable foreign keys
|
||||||
await client.execute("PRAGMA foreign_keys = ON");
|
await client.execute("PRAGMA foreign_keys = ON");
|
||||||
|
const db = drizzle(client);
|
||||||
await client.execute(`
|
await migrate(db, { migrationsFolder });
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await client.execute(`
|
|
||||||
CREATE TABLE IF NOT EXISTS medications (
|
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id integer NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should cascade delete medications when user is deleted", async () => {
|
it("should cascade delete medications when user is deleted", async () => {
|
||||||
@@ -845,18 +559,44 @@ describe("Database Client", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Unique Constraints", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enforce unique constraint on username", async () => {
|
||||||
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.execute("INSERT INTO users (username) VALUES ('testuser')")
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enforce unique constraint on refresh token_id", async () => {
|
||||||
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
await client.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
||||||
|
)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Default User Creation (Auth Disabled)", () => {
|
describe("Default User Creation (Auth Disabled)", () => {
|
||||||
let client: ReturnType<typeof createClient>;
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await client.execute(`
|
const db = drizzle(client);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await migrate(db, { migrationsFolder });
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be able to create a default user with ID 1", async () => {
|
it("should be able to create a default user with ID 1", async () => {
|
||||||
|
|||||||
@@ -80,6 +80,45 @@ async function registerDoseRoutes(ctx: TestContext) {
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /doses/dismiss - Dismiss missed doses without deducting stock
|
||||||
|
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
|
||||||
|
const userId = 1;
|
||||||
|
const { doseIds } = request.body || {};
|
||||||
|
|
||||||
|
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "doseIds array is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let dismissedCount = 0;
|
||||||
|
for (const doseId of doseIds) {
|
||||||
|
// Check if already exists
|
||||||
|
const existing = await client.execute({
|
||||||
|
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
// Update to dismissed if not already
|
||||||
|
if (!existing.rows[0].dismissed) {
|
||||||
|
await client.execute({
|
||||||
|
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
|
||||||
|
args: [existing.rows[0].id],
|
||||||
|
});
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert new dismissed record
|
||||||
|
await client.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, dismissedCount };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -361,4 +400,101 @@ describe("Dose Tracking API", () => {
|
|||||||
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dismiss Doses Tests (POST /doses/dismiss)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("POST /doses/dismiss", () => {
|
||||||
|
it("should dismiss multiple doses", async () => {
|
||||||
|
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
|
||||||
|
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const result = await ctx.client.execute({
|
||||||
|
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(result.rows.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not double-count already dismissed doses", async () => {
|
||||||
|
const doseId = "1-0-1735344000000";
|
||||||
|
|
||||||
|
// Dismiss once
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds: [doseId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss again
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds: [doseId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject empty doseIds array", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject missing doseIds", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
|
||||||
|
const doseId = "1-0-1735344000000";
|
||||||
|
|
||||||
|
// First mark as taken
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/taken",
|
||||||
|
payload: { doseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then dismiss it
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds: [doseId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
|
||||||
|
|
||||||
|
// Verify it's now dismissed
|
||||||
|
const result = await ctx.client.execute({
|
||||||
|
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
expect(result.rows[0].dismissed).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const { shareRoutes } = await import("../routes/share.js");
|
|||||||
const { medicationRoutes } = await import("../routes/medications.js");
|
const { medicationRoutes } = await import("../routes/medications.js");
|
||||||
const { settingsRoutes } = await import("../routes/settings.js");
|
const { settingsRoutes } = await import("../routes/settings.js");
|
||||||
const { healthRoutes } = await import("../routes/health.js");
|
const { healthRoutes } = await import("../routes/health.js");
|
||||||
|
const { refillRoutes } = await import("../routes/refills.js");
|
||||||
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Test Setup
|
// Test Setup
|
||||||
@@ -139,6 +141,17 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
medication_id integer NOT NULL,
|
||||||
|
user_id integer NOT NULL,
|
||||||
|
packs_added integer NOT NULL DEFAULT 0,
|
||||||
|
loose_pills_added integer NOT NULL DEFAULT 0,
|
||||||
|
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
@@ -149,6 +162,7 @@ async function createSchema(client: Client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearData(client: Client) {
|
async function clearData(client: Client) {
|
||||||
|
await client.execute("DELETE FROM refill_history");
|
||||||
await client.execute("DELETE FROM dose_tracking");
|
await client.execute("DELETE FROM dose_tracking");
|
||||||
await client.execute("DELETE FROM share_tokens");
|
await client.execute("DELETE FROM share_tokens");
|
||||||
await client.execute("DELETE FROM user_settings");
|
await client.execute("DELETE FROM user_settings");
|
||||||
@@ -229,6 +243,8 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
await app.register(medicationRoutes);
|
await app.register(medicationRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
|
||||||
await app.ready();
|
await app.ready();
|
||||||
});
|
});
|
||||||
@@ -1567,4 +1583,342 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(response.statusCode).toBe(204);
|
expect(response.statusCode).toBe(204);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Real Refill Routes Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Real /medications/:id/refill routes", () => {
|
||||||
|
it("should add refill to medication stock", async () => {
|
||||||
|
// Create medication first
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Refill Test Med",
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 3,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 5,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
// Add refill
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const data = refillResponse.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.newStock.packCount).toBe(3); // 2 + 1
|
||||||
|
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 when no packs or pills added", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Refill Test Med 2",
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 0, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for non-existent medication", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/99999/refill",
|
||||||
|
payload: { packsAdded: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 for invalid medication id", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/invalid/refill",
|
||||||
|
payload: { packsAdded: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Real /medications/:id/refills routes (history)", () => {
|
||||||
|
it("should return empty array when no refills", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "No Refill Med",
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return refill history after adding refills", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "With Refills Med",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
// Add two refills
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 0, loosePillsAdded: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const refills = response.json();
|
||||||
|
expect(refills).toHaveLength(2);
|
||||||
|
// Check both refills exist (order may vary)
|
||||||
|
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
||||||
|
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5);
|
||||||
|
expect(hasPackRefill).toBe(true);
|
||||||
|
expect(hasLooseRefill).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for non-existent medication", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/medications/99999/refills",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Real Export/Import Routes Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Real /export routes", () => {
|
||||||
|
it("should export empty data when no medications", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.version).toBeDefined();
|
||||||
|
expect(data.exportedAt).toBeDefined();
|
||||||
|
expect(data.medications).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should export medications with correct structure", async () => {
|
||||||
|
// Create a medication
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Export Test Med",
|
||||||
|
genericName: "Test Generic",
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 3,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 5,
|
||||||
|
pillWeightMg: 500,
|
||||||
|
notes: "Test notes",
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.medications).toHaveLength(1);
|
||||||
|
|
||||||
|
const med = data.medications[0];
|
||||||
|
expect(med.name).toBe("Export Test Med");
|
||||||
|
expect(med.genericName).toBe("Test Generic");
|
||||||
|
expect(med.inventory.packCount).toBe(2);
|
||||||
|
expect(med.inventory.blistersPerPack).toBe(3);
|
||||||
|
expect(med.inventory.pillsPerBlister).toBe(10);
|
||||||
|
expect(med.inventory.looseTablets).toBe(5);
|
||||||
|
expect(med.pillWeightMg).toBe(500);
|
||||||
|
expect(med.notes).toBe("Test notes");
|
||||||
|
expect(med.schedules).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include settings when user has settings", async () => {
|
||||||
|
// Create settings first
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
emailEnabled: true,
|
||||||
|
notificationEmail: "test@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.settings).toBeDefined();
|
||||||
|
expect(data.settings.emailEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Real /import routes", () => {
|
||||||
|
it("should import medications from export format", async () => {
|
||||||
|
const importData = {
|
||||||
|
version: "1.0",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
_exportId: "med-1",
|
||||||
|
name: "Imported Med",
|
||||||
|
genericName: "Imported Generic",
|
||||||
|
takenBy: ["Person A"],
|
||||||
|
inventory: {
|
||||||
|
packCount: 3,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 14,
|
||||||
|
looseTablets: 7,
|
||||||
|
},
|
||||||
|
pillWeightMg: 250,
|
||||||
|
schedules: [
|
||||||
|
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z", remind: true }
|
||||||
|
],
|
||||||
|
notes: "Imported notes",
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: importData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const result = response.json();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.imported.medications).toBe(1);
|
||||||
|
|
||||||
|
// Verify medication was created
|
||||||
|
const medsResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/medications",
|
||||||
|
});
|
||||||
|
const meds = medsResponse.json();
|
||||||
|
expect(meds).toHaveLength(1);
|
||||||
|
expect(meds[0].name).toBe("Imported Med");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 for invalid import data", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: { invalid: "data" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should replace existing medications on import", async () => {
|
||||||
|
// First create a medication
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Existing Med",
|
||||||
|
packCount: 5,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
let medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.json()).toHaveLength(1);
|
||||||
|
expect(medsResponse.json()[0].name).toBe("Existing Med");
|
||||||
|
expect(medsResponse.json()[0].packCount).toBe(5);
|
||||||
|
|
||||||
|
// Import will REPLACE all data
|
||||||
|
const importData = {
|
||||||
|
version: "1.0",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
_exportId: "med-1",
|
||||||
|
name: "Imported Med",
|
||||||
|
inventory: { packCount: 10, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
|
||||||
|
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: importData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const result = response.json();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.imported.medications).toBe(1);
|
||||||
|
|
||||||
|
// Verify: old med is gone, new med exists
|
||||||
|
medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.json()).toHaveLength(1);
|
||||||
|
expect(medsResponse.json()[0].name).toBe("Imported Med");
|
||||||
|
expect(medsResponse.json()[0].packCount).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,8 +9,15 @@ import sensible from "@fastify/sensible";
|
|||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
import { createClient, Client } from "@libsql/client";
|
import { createClient, Client } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { beforeAll, afterAll, beforeEach } from "vitest";
|
import { beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
import { getTableCreationSQL } from "../db/schema-sql.js";
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// Get migrations folder path
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
// Type for our test database
|
// Type for our test database
|
||||||
export type TestDb = ReturnType<typeof drizzle>;
|
export type TestDb = ReturnType<typeof drizzle>;
|
||||||
@@ -61,14 +68,11 @@ export async function buildTestApp(): Promise<TestContext> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create test database schema
|
* Create test database schema using drizzle-kit migrations
|
||||||
*/
|
*/
|
||||||
async function runTestMigrations(client: Client): Promise<void> {
|
async function runTestMigrations(client: Client): Promise<void> {
|
||||||
const tableCreations = getTableCreationSQL();
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
for (const sql of tableCreations) {
|
|
||||||
await client.execute(sql);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -282,6 +286,7 @@ export async function closeTestApp(ctx: TestContext): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function clearTestData(client: Client): Promise<void> {
|
export async function clearTestData(client: Client): Promise<void> {
|
||||||
// Order matters due to foreign keys
|
// Order matters due to foreign keys
|
||||||
|
await client.execute("DELETE FROM refill_history");
|
||||||
await client.execute("DELETE FROM dose_tracking");
|
await client.execute("DELETE FROM dose_tracking");
|
||||||
await client.execute("DELETE FROM share_tokens");
|
await client.execute("DELETE FROM share_tokens");
|
||||||
await client.execute("DELETE FROM refresh_tokens");
|
await client.execute("DELETE FROM refresh_tokens");
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -38,7 +38,14 @@
|
|||||||
"pastDaysCount": "{{count}} Tag",
|
"pastDaysCount": "{{count}} Tag",
|
||||||
"pastDaysCount_other": "{{count}} Tage",
|
"pastDaysCount_other": "{{count}} Tage",
|
||||||
"missedDoses": "{{count}} verpasste Dosis",
|
"missedDoses": "{{count}} verpasste Dosis",
|
||||||
"missedDoses_other": "{{count}} verpasste Dosen"
|
"missedDoses_other": "{{count}} verpasste Dosen",
|
||||||
|
"clearMissed": "Verpasste löschen",
|
||||||
|
"clearMissedConfirmTitle": "Verpasste Dosen löschen?",
|
||||||
|
"clearMissedConfirmMessage": "{{count}} verpasste Dosis wird als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
|
||||||
|
"clearMissedConfirmMessage_other": "{{count}} verpasste Dosen werden als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
|
||||||
|
"clearMissedConfirm": "Ja, löschen",
|
||||||
|
"clearMissedCancel": "Abbrechen",
|
||||||
|
"clearMissedSuccess": "{{count}} verpasste Dosen gelöscht"
|
||||||
},
|
},
|
||||||
"reminders": {
|
"reminders": {
|
||||||
"active": "Automatische Erinnerungen aktiv",
|
"active": "Automatische Erinnerungen aktiv",
|
||||||
@@ -91,8 +98,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Eintrag bearbeiten",
|
"editEntry": "Medikament bearbeiten",
|
||||||
"newEntry": "Neuer Eintrag",
|
"newEntry": "Neues Medikament",
|
||||||
"badge": "Packungen + lose Tabletten",
|
"badge": "Packungen + lose Tabletten",
|
||||||
"commercialName": "Handelsname",
|
"commercialName": "Handelsname",
|
||||||
"genericName": "Wirkstoff",
|
"genericName": "Wirkstoff",
|
||||||
@@ -219,7 +226,7 @@
|
|||||||
"pillWeight": "Tablettengewicht",
|
"pillWeight": "Tablettengewicht",
|
||||||
"expiryDate": "Ablaufdatum",
|
"expiryDate": "Ablaufdatum",
|
||||||
"intakeSchedule": "Einnahmeplan",
|
"intakeSchedule": "Einnahmeplan",
|
||||||
"coverageStatus": "Reichweite",
|
"coverageStatus": "Bestand",
|
||||||
"daysLeft": "Tage übrig",
|
"daysLeft": "Tage übrig",
|
||||||
"runsOut": "Aufgebraucht",
|
"runsOut": "Aufgebraucht",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
@@ -376,5 +383,21 @@
|
|||||||
"importError": "Daten konnten nicht importiert werden",
|
"importError": "Daten konnten nicht importiert werden",
|
||||||
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.",
|
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.",
|
||||||
"downloadFilename": "medassist-export"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,14 @@
|
|||||||
"pastDaysCount": "{{count}} day",
|
"pastDaysCount": "{{count}} day",
|
||||||
"pastDaysCount_other": "{{count}} days",
|
"pastDaysCount_other": "{{count}} days",
|
||||||
"missedDoses": "{{count}} missed dose",
|
"missedDoses": "{{count}} missed dose",
|
||||||
"missedDoses_other": "{{count}} missed doses"
|
"missedDoses_other": "{{count}} missed doses",
|
||||||
|
"clearMissed": "Clear missed",
|
||||||
|
"clearMissedConfirmTitle": "Clear Missed Doses?",
|
||||||
|
"clearMissedConfirmMessage": "This will mark {{count}} missed dose as acknowledged without deducting from your stock.",
|
||||||
|
"clearMissedConfirmMessage_other": "This will mark {{count}} missed doses as acknowledged without deducting from your stock.",
|
||||||
|
"clearMissedConfirm": "Yes, Clear",
|
||||||
|
"clearMissedCancel": "Cancel",
|
||||||
|
"clearMissedSuccess": "Cleared {{count}} missed doses"
|
||||||
},
|
},
|
||||||
"reminders": {
|
"reminders": {
|
||||||
"active": "Automatic reminders active",
|
"active": "Automatic reminders active",
|
||||||
@@ -93,8 +100,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Edit entry",
|
"editEntry": "Edit medication",
|
||||||
"newEntry": "New entry",
|
"newEntry": "New medication",
|
||||||
"badge": "Packs + loose pills",
|
"badge": "Packs + loose pills",
|
||||||
"commercialName": "Commercial Name",
|
"commercialName": "Commercial Name",
|
||||||
"genericName": "Generic Name",
|
"genericName": "Generic Name",
|
||||||
@@ -378,5 +385,21 @@
|
|||||||
"importError": "Failed to import data",
|
"importError": "Failed to import data",
|
||||||
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.",
|
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.",
|
||||||
"downloadFilename": "medassist-export"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,6 +431,46 @@ button.secondary:hover {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Success button (Refill, etc.) */
|
||||||
|
button.success {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
button.success:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
button.success:disabled {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary/Accent button (New entry, Add intake, etc.) */
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info button (Edit, secondary actions) */
|
||||||
|
button.info {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
button.info:hover {
|
||||||
|
background: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ghost button (Cancel, etc.) */
|
/* Ghost button (Cancel, etc.) */
|
||||||
button.ghost {
|
button.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -637,6 +677,7 @@ textarea.auto-resize {
|
|||||||
.past-days-toggle {
|
.past-days-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
@@ -662,6 +703,7 @@ textarea.auto-resize {
|
|||||||
.past-days-icon {
|
.past-days-icon {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.past-days-label {
|
.past-days-label {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -669,6 +711,8 @@ textarea.auto-resize {
|
|||||||
.past-days-count {
|
.past-days-count {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.past-days-warning {
|
.past-days-warning {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -690,6 +734,35 @@ textarea.auto-resize {
|
|||||||
background: rgba(234, 179, 8, 0.08);
|
background: rgba(234, 179, 8, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Past days header container - toggle + clear button */
|
||||||
|
.past-days-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.past-days-header .past-days-toggle {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.clear-missed-btn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: var(--warning);
|
||||||
|
border: 1px solid var(--warning);
|
||||||
|
border-radius: var(--btn-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 150ms ease, transform 100ms ease;
|
||||||
|
}
|
||||||
|
.clear-missed-btn:hover {
|
||||||
|
background: rgba(234, 179, 8, 0.25);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.clear-missed-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
/* Past day blocks styling */
|
/* Past day blocks styling */
|
||||||
.day-block.past {
|
.day-block.past {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -1359,13 +1432,21 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.setting-row.language-row {
|
.setting-row.language-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row.language-row .setting-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-select {
|
.language-select {
|
||||||
width: auto;
|
flex: 1 1 auto;
|
||||||
min-width: 160px;
|
min-width: 140px;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -1634,11 +1715,42 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-tooltip:hover::after,
|
.info-tooltip:hover::after,
|
||||||
.info-tooltip:hover::before {
|
.info-tooltip:hover::before,
|
||||||
|
.info-tooltip:focus::after,
|
||||||
|
.info-tooltip:focus::before,
|
||||||
|
.info-tooltip.tooltip-active::after,
|
||||||
|
.info-tooltip.tooltip-active::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile tooltip - disable hover, use click only */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.info-tooltip:hover::after,
|
||||||
|
.info-tooltip:hover::before {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tooltip.tooltip-active::after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
bottom: auto;
|
||||||
|
right: auto;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
width: max-content;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tooltip::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Channels Overview */
|
/* Channels Overview */
|
||||||
.channels-overview {
|
.channels-overview {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1947,7 +2059,8 @@ textarea.auto-resize {
|
|||||||
.schedule-row {
|
.schedule-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -1960,11 +2073,27 @@ textarea.auto-resize {
|
|||||||
|
|
||||||
.schedule-label {
|
.schedule-label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-value {
|
.schedule-value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: stack schedule rows vertically when text is long */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.schedule-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.schedule-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Legacy support for old channel-btn (can remove later) */
|
/* Legacy support for old channel-btn (can remove later) */
|
||||||
@@ -2061,10 +2190,13 @@ textarea.auto-resize {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact Setting Row */
|
/* Compact Setting Row - for inline toggles without card styling */
|
||||||
.setting-row.compact {
|
.setting-row.compact {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 0;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-row.compact .setting-label {
|
.setting-row.compact .setting-label {
|
||||||
@@ -2255,6 +2387,9 @@ textarea.auto-resize {
|
|||||||
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
|
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
|
||||||
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
|
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
|
||||||
|
|
||||||
|
.med-avatar.clickable { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
|
||||||
|
.med-avatar.clickable:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||||
|
|
||||||
/* Table/Timeline cells with avatar */
|
/* Table/Timeline cells with avatar */
|
||||||
.cell-with-avatar {
|
.cell-with-avatar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2856,14 +2991,22 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lightbox-image {
|
.lightbox-image {
|
||||||
max-width: 90vw;
|
max-width: 50vw;
|
||||||
max-height: 85vh;
|
max-height: 50vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
animation: zoomIn 0.3s ease;
|
animation: zoomIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: larger lightbox image */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.lightbox-image {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes zoomIn {
|
@keyframes zoomIn {
|
||||||
from { opacity: 0; transform: scale(0.8); }
|
from { opacity: 0; transform: scale(0.8); }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: scale(1); }
|
||||||
@@ -2903,6 +3046,22 @@ textarea.auto-resize {
|
|||||||
|
|
||||||
.med-detail-footer {
|
.med-detail-footer {
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-detail-footer > button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-detail-footer .footer-actions {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-detail-footer button {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-detail-grid {
|
.med-detail-grid {
|
||||||
@@ -3254,7 +3413,17 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
|
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu:hover .user-dropdown {
|
/* Only use hover on devices that support it (not touch) */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.user-menu:hover .user-dropdown {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Click-based open for all devices */
|
||||||
|
.user-menu.open .user-dropdown {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
@@ -3688,6 +3857,215 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Refill Modal & History
|
||||||
|
============================================================================= */
|
||||||
|
.refill-modal {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-modal h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-med-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form input {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-footer-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-footer-right .refill-preview {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refill modal footer mobile */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.refill-modal .modal-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-modal .modal-footer > button,
|
||||||
|
.refill-modal .modal-footer .refill-footer-right {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-modal .modal-footer .refill-footer-right {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-modal .modal-footer .refill-footer-right button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline refill form in edit modal */
|
||||||
|
.refill-form-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form-inline label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form-inline label input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form-inline button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-preview {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--success);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section {
|
||||||
|
border-left: 3px solid var(--success);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem 1rem 1rem 1.25rem;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section .refill-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section .refill-form-inline button {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section .refill-form-inline button:hover:not(:disabled) {
|
||||||
|
background: var(--success-hover, #3aa865);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section .refill-form-inline button:disabled {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable section header (for expand/collapse) */
|
||||||
|
.section-header-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-clickable:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refill history in detail modal */
|
||||||
|
.refill-history-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-header .collapse-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-header .refill-count {
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-list {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-date {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-details {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested modal overlay */
|
||||||
|
.modal-overlay.nested {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
Shared Schedule Page (Public)
|
Shared Schedule Page (Public)
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
@@ -3978,78 +4356,59 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Export/Import Section */
|
/* Action Cards (for Export/Import etc.) - similar to radio-card */
|
||||||
.card:has(.export-import-grid) {
|
.action-card {
|
||||||
overflow: visible;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
.card:has(.export-import-grid) .card-head {
|
gap: 1rem;
|
||||||
overflow: visible;
|
padding: 1rem 1.25rem;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-secondary);
|
border: 1px solid var(--border-secondary);
|
||||||
border-radius: var(--card-radius);
|
border-radius: 12px;
|
||||||
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;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-import-file-btn:hover {
|
.action-card:hover {
|
||||||
background: var(--bg-hover);
|
|
||||||
border-color: var(--border-primary);
|
border-color: var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-import-file-btn input {
|
.action-card-content {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||