Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82b2be48cd | |||
| 269a549563 | |||
| 055c0dfe10 | |||
| 318f63657b |
@@ -195,31 +195,31 @@ 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.
|
||||||
|
|
||||||
|
**Keep it short!** Less is more. Users want to know what changed, not implementation details.
|
||||||
|
|
||||||
**Structure of a release text:**
|
**Structure of a release text:**
|
||||||
|
|
||||||
1. **Intro** (1-2 sentences): What's new, what was improved?
|
1. **Intro** (1-2 sentences): What's new in plain language
|
||||||
2. **Features & Changes**: Brief list of key changes
|
2. **Features** (if needed): Very brief bullet points
|
||||||
3. **Breaking Changes Warning** (if applicable): See below
|
3. **Breaking Changes Warning** (if applicable): See below
|
||||||
4. **Optional**: Acknowledgements, documentation links
|
|
||||||
|
**DO NOT include:**
|
||||||
|
- ❌ Technical implementation details (new columns, endpoints, etc.)
|
||||||
|
- ❌ "How it works" step-by-step explanations
|
||||||
|
- ❌ Number of tests added
|
||||||
|
- ❌ Internal API changes (unless breaking)
|
||||||
|
|
||||||
**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.
|
Added the ability to clear missed dose warnings without affecting medication stock.
|
||||||
|
A "Clear missed" button now appears next to the "Show past days" toggle.
|
||||||
|
|
||||||
### New Features
|
### Features
|
||||||
- 🔔 Intake reminder notifications with configurable nagging intervals
|
- 🧹 Clear missed doses with one click
|
||||||
- 📊 Enhanced stock calculation with blister tracking
|
- ✅ Confirmation dialog to prevent accidents
|
||||||
- 🌐 German translation improvements
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- Fixed timezone handling in dose scheduling
|
|
||||||
- Improved image upload validation
|
|
||||||
|
|
||||||
### Full Changelog
|
|
||||||
[All commits since v1.2.0](link)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Breaking Changes Warning (CRITICAL!)
|
### Breaking Changes Warning (CRITICAL!)
|
||||||
@@ -455,40 +455,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 +514,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` |
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
- Email via SMTP
|
- Email via SMTP
|
||||||
- Push notifications via ntfy, Gotify, Telegram, Discord (Shoutrrr)
|
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
||||||
- Supports both stock warnings and intake reminders
|
- Supports both stock warnings and intake reminders
|
||||||
|
|
||||||
### Privacy & Security
|
### Privacy & Security
|
||||||
@@ -148,6 +148,54 @@ Generate secrets with: `openssl rand -hex 32`
|
|||||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
||||||
|
|
||||||
|
### Push Notifications (Shoutrrr)
|
||||||
|
|
||||||
|
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||||
|
|
||||||
|
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||||
|
|
||||||
|
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
|
||||||
|
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
|
||||||
|
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
|
||||||
|
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
|
||||||
|
|
||||||
|
#### URL Examples
|
||||||
|
|
||||||
|
**ntfy** (free, self-hostable):
|
||||||
|
```
|
||||||
|
ntfy://ntfy.sh/your-topic
|
||||||
|
ntfy://user:password@your-server.com/topic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pushover** (free app for iOS/Android):
|
||||||
|
```
|
||||||
|
pushover://shoutrrr:API_TOKEN@USER_KEY/
|
||||||
|
```
|
||||||
|
Get your keys at [pushover.net](https://pushover.net/):
|
||||||
|
- **User Key**: Shown on your dashboard (top right)
|
||||||
|
- **API Token**: Create an application → copy the API Token
|
||||||
|
|
||||||
|
**Gotify** (self-hosted):
|
||||||
|
```
|
||||||
|
gotify://your-server.com/TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Discord**:
|
||||||
|
```
|
||||||
|
discord://TOKEN@WEBHOOK_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Telegram**:
|
||||||
|
```
|
||||||
|
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+1009
-19
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
+34
-19
@@ -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`);
|
||||||
|
|
||||||
|
|||||||
+29
-25
@@ -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 {
|
||||||
|
|||||||
+104
-1
@@ -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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
+201
-461
@@ -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");
|
||||||
|
|||||||
+492
-51
@@ -39,6 +39,13 @@ type PlannerRow = {
|
|||||||
enough: boolean;
|
enough: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RefillEntry = {
|
||||||
|
id: number;
|
||||||
|
packsAdded: number;
|
||||||
|
loosePillsAdded: number;
|
||||||
|
refillDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
type FormBlister = { usage: string; every: string; startDate: string; startTime: string };
|
type FormBlister = { usage: string; every: string; startDate: string; startTime: string };
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
@@ -337,10 +344,15 @@ function AppContent() {
|
|||||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||||
|
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
||||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
||||||
const [showPastDays, setShowPastDays] = useState(false);
|
const [showPastDays, setShowPastDays] = useState(false);
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
|
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
|
// Clear missed doses confirmation dialog
|
||||||
|
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||||
|
const [clearingMissed, setClearingMissed] = useState(false);
|
||||||
// Tag input state for "Taken By" field
|
// Tag input state for "Taken By" field
|
||||||
const [takenByInput, setTakenByInput] = useState("");
|
const [takenByInput, setTakenByInput] = useState("");
|
||||||
// Share dialog state
|
// Share dialog state
|
||||||
@@ -354,9 +366,18 @@ function AppContent() {
|
|||||||
// Export/Import state
|
// Export/Import state
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
// User dropdown state (for mobile click-based behavior)
|
||||||
|
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
const [pendingImportData, setPendingImportData] = useState<any>(null);
|
const [pendingImportData, setPendingImportData] = useState<any>(null);
|
||||||
|
// Refill state
|
||||||
|
const [showRefillModal, setShowRefillModal] = useState(false);
|
||||||
|
const [refillPacks, setRefillPacks] = useState(1);
|
||||||
|
const [refillLoose, setRefillLoose] = useState(0);
|
||||||
|
const [refillSaving, setRefillSaving] = useState(false);
|
||||||
|
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
|
||||||
|
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
|
||||||
// Collapsed days state (manually collapsed days are persisted)
|
// Collapsed days state (manually collapsed days are persisted)
|
||||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||||
@@ -384,7 +405,17 @@ function AppContent() {
|
|||||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
|
const taken = new Set<string>();
|
||||||
|
const dismissed = new Set<string>();
|
||||||
|
for (const d of data.doses) {
|
||||||
|
if (d.dismissed) {
|
||||||
|
dismissed.add(d.doseId);
|
||||||
|
} else {
|
||||||
|
taken.add(d.doseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTakenDoses(taken);
|
||||||
|
setDismissedDoses(dismissed);
|
||||||
}
|
}
|
||||||
// Don't reset on error - keep current state
|
// Don't reset on error - keep current state
|
||||||
} catch {
|
} catch {
|
||||||
@@ -467,12 +498,45 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dismiss missed doses without deducting from stock
|
||||||
|
async function dismissMissedDoses(doseIds: string[]) {
|
||||||
|
if (doseIds.length === 0) return;
|
||||||
|
|
||||||
|
setClearingMissed(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/doses/dismiss", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ doseIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Update local state - move these from neither set to dismissed set
|
||||||
|
setDismissedDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of doseIds) next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setShowClearMissedConfirm(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error - dialog stays open
|
||||||
|
} finally {
|
||||||
|
setClearingMissed(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
// Close modals in order of priority (topmost first)
|
// Close modals in order of priority (topmost first)
|
||||||
if (showImageLightbox) {
|
if (userDropdownOpen) {
|
||||||
|
setUserDropdownOpen(false);
|
||||||
|
} else if (scheduleLightboxImage) {
|
||||||
|
setScheduleLightboxImage(null);
|
||||||
|
} else if (showImageLightbox) {
|
||||||
setShowImageLightbox(false);
|
setShowImageLightbox(false);
|
||||||
} else if (showEditModal) {
|
} else if (showEditModal) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
@@ -490,7 +554,54 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleEscape);
|
document.addEventListener("keydown", handleEscape);
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [selectedMed, showImageLightbox, selectedUser, showProfile, showShareDialog, showEditModal]);
|
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, userDropdownOpen]);
|
||||||
|
|
||||||
|
// Close user dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userDropdownOpen) return;
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest('.user-menu')) {
|
||||||
|
setUserDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("click", handleClickOutside);
|
||||||
|
}, [userDropdownOpen]);
|
||||||
|
|
||||||
|
// Close tooltips on scroll/touch (for mobile)
|
||||||
|
useEffect(() => {
|
||||||
|
const closeAllTooltips = () => {
|
||||||
|
document.querySelectorAll('.info-tooltip.tooltip-active').forEach(el => {
|
||||||
|
el.classList.remove('tooltip-active');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTooltipClick = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.classList.contains('info-tooltip')) {
|
||||||
|
// Close other tooltips first
|
||||||
|
closeAllTooltips();
|
||||||
|
// Toggle this one
|
||||||
|
target.classList.add('tooltip-active');
|
||||||
|
} else {
|
||||||
|
closeAllTooltips();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = () => {
|
||||||
|
closeAllTooltips();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleTooltipClick, { capture: true });
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||||
|
document.addEventListener('scroll', handleTouchMove, { passive: true });
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleTooltipClick, { capture: true });
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
document.removeEventListener('scroll', handleTouchMove);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Prevent background scroll when modal is open
|
// Prevent background scroll when modal is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -513,6 +624,20 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
}, [selectedMed, selectedUser, showProfile, showShareDialog, showEditModal]);
|
}, [selectedMed, selectedUser, showProfile, showShareDialog, showEditModal]);
|
||||||
|
|
||||||
|
// Update selectedMed when meds change (e.g., after refill)
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedMed) {
|
||||||
|
const updated = meds.find(m => m.id === selectedMed.id);
|
||||||
|
if (updated && (
|
||||||
|
updated.packCount !== selectedMed.packCount ||
|
||||||
|
updated.looseTablets !== selectedMed.looseTablets ||
|
||||||
|
updated.updatedAt !== selectedMed.updatedAt
|
||||||
|
)) {
|
||||||
|
setSelectedMed(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [meds, selectedMed]);
|
||||||
|
|
||||||
// Check if settings have changed
|
// Check if settings have changed
|
||||||
const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled ||
|
const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled ||
|
||||||
settings.notificationEmail !== savedSettings.notificationEmail ||
|
settings.notificationEmail !== savedSettings.notificationEmail ||
|
||||||
@@ -580,6 +705,20 @@ function AppContent() {
|
|||||||
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
|
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
|
||||||
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
|
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
|
||||||
|
|
||||||
|
// Calculate missed past dose IDs for the "Clear missed" feature
|
||||||
|
const missedPastDoseIds = useMemo(() => {
|
||||||
|
const totalPastDoses = pastDays.flatMap(d =>
|
||||||
|
d.meds.flatMap(m =>
|
||||||
|
m.doses.flatMap(dose =>
|
||||||
|
(dose.takenBy || []).length > 0
|
||||||
|
? dose.takenBy.map((p: string) => `${dose.id}-${p}`)
|
||||||
|
: [dose.id]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return totalPastDoses.filter(id => !takenDoses.has(id) && !dismissedDoses.has(id));
|
||||||
|
}, [pastDays, takenDoses, dismissedDoses]);
|
||||||
|
|
||||||
// Load medications and settings when user changes (or on initial mount)
|
// Load medications and settings when user changes (or on initial mount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMeds();
|
loadMeds();
|
||||||
@@ -682,6 +821,65 @@ function AppContent() {
|
|||||||
setSettingsSaved(true);
|
setSettingsSaved(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load refill history for a medication
|
||||||
|
async function loadRefillHistory(medId: number) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setRefillHistory(Array.isArray(data) ? data : (data.refills || []));
|
||||||
|
} else {
|
||||||
|
setRefillHistory([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRefillHistory([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit a refill
|
||||||
|
async function submitRefill(medId: number) {
|
||||||
|
if (refillPacks < 1 && refillLoose < 1) return;
|
||||||
|
setRefillSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/medications/${medId}/refill`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
// Update form values if we're in edit mode
|
||||||
|
if (editingId === medId && data.newStock) {
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
packCount: String(data.newStock.packCount),
|
||||||
|
looseTablets: String(data.newStock.looseTablets),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Reset refill form
|
||||||
|
setRefillPacks(1);
|
||||||
|
setRefillLoose(0);
|
||||||
|
setShowRefillModal(false);
|
||||||
|
// Reload medications to get updated stock
|
||||||
|
loadMeds();
|
||||||
|
// Reload refill history
|
||||||
|
await loadRefillHistory(medId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setRefillSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to open medication detail modal with refill history
|
||||||
|
function openMedDetail(med: Medication) {
|
||||||
|
setSelectedMed(med);
|
||||||
|
setRefillHistory([]);
|
||||||
|
setRefillHistoryExpanded(false);
|
||||||
|
loadRefillHistory(med.id);
|
||||||
|
}
|
||||||
|
|
||||||
async function testEmail() {
|
async function testEmail() {
|
||||||
if (!settings.notificationEmail) return;
|
if (!settings.notificationEmail) return;
|
||||||
setTestingEmail(true);
|
setTestingEmail(true);
|
||||||
@@ -1229,8 +1427,8 @@ function AppContent() {
|
|||||||
{theme === "dark" ? "☀️" : "🌙"}
|
{theme === "dark" ? "☀️" : "🌙"}
|
||||||
</button>
|
</button>
|
||||||
{authState?.authEnabled && user && (
|
{authState?.authEnabled && user && (
|
||||||
<div className="user-menu">
|
<div className={`user-menu ${userDropdownOpen ? 'open' : ''}`}>
|
||||||
<button className="user-menu-btn">
|
<button className="user-menu-btn" onClick={() => setUserDropdownOpen(!userDropdownOpen)}>
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
|
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
|
||||||
) : (
|
) : (
|
||||||
@@ -1247,15 +1445,15 @@ function AppContent() {
|
|||||||
<span className="dropdown-username">{user.username}</span>
|
<span className="dropdown-username">{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="dropdown-menu">
|
<div className="dropdown-menu">
|
||||||
<button className="dropdown-item" onClick={() => setShowProfile(true)}>
|
<button className="dropdown-item" onClick={() => { setShowProfile(true); setUserDropdownOpen(false); }}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
{t('auth.profile', 'Profile')}
|
{t('auth.profile', 'Profile')}
|
||||||
</button>
|
</button>
|
||||||
<button className="dropdown-item" onClick={() => navigate('/settings')}>
|
<button className="dropdown-item" onClick={() => { navigate('/settings'); setUserDropdownOpen(false); }}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
{t('nav.settings', 'Settings')}
|
{t('nav.settings', 'Settings')}
|
||||||
</button>
|
</button>
|
||||||
<button className="dropdown-item danger" onClick={() => logout()}>
|
<button className="dropdown-item danger" onClick={() => { logout(); setUserDropdownOpen(false); }}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
{t('auth.signOut', 'Sign Out')}
|
{t('auth.signOut', 'Sign Out')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1341,7 +1539,7 @@ function AppContent() {
|
|||||||
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
|
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||||
<span data-label={t('table.name')} className="cell-with-avatar">
|
<span data-label={t('table.name')} className="cell-with-avatar">
|
||||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||||
<span className="med-name-text">{row.name}</span>
|
<span className="med-name-text">{row.name}</span>
|
||||||
@@ -1410,7 +1608,7 @@ function AppContent() {
|
|||||||
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
|
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||||
<span data-label={t('table.name')} className="cell-with-avatar">
|
<span data-label={t('table.name')} className="cell-with-avatar">
|
||||||
<span className="med-name-line">
|
<span className="med-name-line">
|
||||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||||
@@ -1467,31 +1665,46 @@ function AppContent() {
|
|||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{/* Past days toggle */}
|
{/* Past days toggle */}
|
||||||
{pastDays.length > 0 && (() => {
|
{pastDays.length > 0 && (() => {
|
||||||
|
const missedCount = missedPastDoseIds.length;
|
||||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||||
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="past-days-header">
|
||||||
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedPastDoses > 0 ? 'has-missed' : ''}`}
|
<div
|
||||||
onClick={() => setShowPastDays(!showPastDays)}
|
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedCount > 0 ? 'has-missed' : ''}`}
|
||||||
>
|
onClick={() => setShowPastDays(!showPastDays)}
|
||||||
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
>
|
||||||
<span className="past-days-label">
|
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
||||||
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
<span className="past-days-label">
|
||||||
</span>
|
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
||||||
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
</span>
|
||||||
{missedPastDoses > 0 ? (
|
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
||||||
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedPastDoses })}>⚠️ {missedPastDoses}</span>
|
{missedCount > 0 ? (
|
||||||
) : totalPastDoses.length > 0 ? (
|
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedCount })}>⚠️ {missedCount}</span>
|
||||||
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}>✓</span>
|
) : totalPastDoses.length > 0 ? (
|
||||||
) : null}
|
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}>✓</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{missedCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clear-missed-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowClearMissedConfirm(true);
|
||||||
|
}}
|
||||||
|
title={t('dashboard.schedules.clearMissed')}
|
||||||
|
>
|
||||||
|
{t('dashboard.schedules.clearMissed')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* Past days (when expanded) */}
|
{/* Past days (when expanded) */}
|
||||||
{showPastDays && pastDays.map((day) => {
|
{showPastDays && pastDays.map((day) => {
|
||||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
|
||||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
|
||||||
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isCollapsed = !isManuallyExpanded;
|
const isCollapsed = !isManuallyExpanded;
|
||||||
@@ -1523,7 +1736,15 @@ function AppContent() {
|
|||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||||
<div className="time-main">
|
<div className="time-main">
|
||||||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
|
<div className="med-name">
|
||||||
|
<div
|
||||||
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
|
onClick={() => med?.imageUrl && setScheduleLightboxImage(`/api/images/${med.imageUrl}`)}
|
||||||
|
>
|
||||||
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
|
</div>
|
||||||
|
<span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
|
||||||
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1626,7 +1847,15 @@ function AppContent() {
|
|||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||||
<div className="time-main">
|
<div className="time-main">
|
||||||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
|
<div className="med-name">
|
||||||
|
<div
|
||||||
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
|
onClick={() => med?.imageUrl && setScheduleLightboxImage(`/api/images/${med.imageUrl}`)}
|
||||||
|
>
|
||||||
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
|
</div>
|
||||||
|
<span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
|
||||||
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||||
{status && <span className={`tag ${status.className}`}>
|
{status && <span className={`tag ${status.className}`}>
|
||||||
@@ -1679,6 +1908,35 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Clear Missed Doses Confirmation Modal */}
|
||||||
|
{showClearMissedConfirm && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowClearMissedConfirm(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||||
|
<button className="modal-close" onClick={() => setShowClearMissedConfirm(false)}>×</button>
|
||||||
|
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('dashboard.schedules.clearMissedConfirmTitle')}</h2>
|
||||||
|
<p style={{marginBottom: "24px"}}>{t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}</p>
|
||||||
|
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => setShowClearMissedConfirm(false)}
|
||||||
|
disabled={clearingMissed}
|
||||||
|
>
|
||||||
|
{t('dashboard.schedules.clearMissedCancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={() => dismissMissedDoses(missedPastDoseIds)}
|
||||||
|
disabled={clearingMissed}
|
||||||
|
>
|
||||||
|
{clearingMissed ? t('common.loading') : t('dashboard.schedules.clearMissedConfirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -1687,6 +1945,19 @@ function AppContent() {
|
|||||||
<article className="card meds">
|
<article className="card meds">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{t('medications.list.title')}</h2>
|
<h2>{t('medications.list.title')}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary small"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
// On mobile, open the edit modal
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
setShowEditModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ {t('form.newEntry')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-list">
|
<div className="med-list">
|
||||||
{meds.map((med) => (
|
{meds.map((med) => (
|
||||||
@@ -1706,7 +1977,7 @@ function AppContent() {
|
|||||||
<div className="med-total">{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {t('common.pills')}</div>
|
<div className="med-total">{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {t('common.pills')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-actions">
|
<div className="med-actions">
|
||||||
<button className="secondary" onClick={() => startEdit(med)}>{t('common.edit')}</button>
|
<button className="info" onClick={() => startEdit(med)}>{t('common.edit')}</button>
|
||||||
<button className="danger" onClick={() => deleteMed(med.id)}>{t('common.delete')}</button>
|
<button className="danger" onClick={() => deleteMed(med.id)}>{t('common.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1808,6 +2079,44 @@ function AppContent() {
|
|||||||
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
|
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Refill section - only shown when editing */}
|
||||||
|
{editingId && (
|
||||||
|
<div className="full refill-section">
|
||||||
|
<h4 className="refill-title">{t('refill.title')}</h4>
|
||||||
|
<div className="refill-form-inline">
|
||||||
|
<label>
|
||||||
|
{t('refill.packs')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={refillPacks}
|
||||||
|
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('refill.loosePills')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={refillLoose}
|
||||||
|
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="success"
|
||||||
|
onClick={() => submitRefill(editingId)}
|
||||||
|
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||||
|
>
|
||||||
|
{refillSaving ? t('refill.adding') : t('refill.button')}
|
||||||
|
</button>
|
||||||
|
{(refillPacks > 0 || refillLoose > 0) && (
|
||||||
|
<span className="refill-preview">+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
|
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
|
||||||
{t('form.notes')}
|
{t('form.notes')}
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1839,7 +2148,7 @@ function AppContent() {
|
|||||||
/>
|
/>
|
||||||
<span>🔔 {t('form.blisters.remind')}</span>
|
<span>🔔 {t('form.blisters.remind')}</span>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="ghost" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
|
<button type="button" className="primary" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{form.blisters.map((s, idx) => (
|
{form.blisters.map((s, idx) => (
|
||||||
@@ -1967,7 +2276,7 @@ function AppContent() {
|
|||||||
{plannerRows.map((row) => {
|
{plannerRows.map((row) => {
|
||||||
const med = meds.find(m => m.name === row.medicationName);
|
const med = meds.find(m => m.name === row.medicationName);
|
||||||
return (
|
return (
|
||||||
<div key={row.medicationId} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||||
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
|
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
|
||||||
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong> {t('common.pills')}</span>
|
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong> {t('common.pills')}</span>
|
||||||
<span data-label={t('planner.table.blisters')}>{row.blistersNeeded} × {row.blisterSize}</span>
|
<span data-label={t('planner.table.blisters')}>{row.blistersNeeded} × {row.blisterSize}</span>
|
||||||
@@ -2095,7 +2404,7 @@ function AppContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Skip reminders for taken doses */}
|
{/* Skip reminders for taken doses */}
|
||||||
<div className="setting-row compact" style={{marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border-color)"}}>
|
<div className="setting-row compact" style={{marginTop: "16px"}}>
|
||||||
<label className="setting-label">
|
<label className="setting-label">
|
||||||
{t('settings.notifications.skipTakenDoses')}
|
{t('settings.notifications.skipTakenDoses')}
|
||||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}>ⓘ</span>
|
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}>ⓘ</span>
|
||||||
@@ -2243,12 +2552,12 @@ function AppContent() {
|
|||||||
<span className="field-label">{t('settings.push.url')}</span>
|
<span className="field-label">{t('settings.push.url')}</span>
|
||||||
<div className="input-with-tooltip">
|
<div className="input-with-tooltip">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="text"
|
||||||
value={settings.shoutrrrUrl}
|
value={settings.shoutrrrUrl}
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||||
placeholder="https://ntfy.sh/your-topic"
|
placeholder={t('settings.push.urlPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<span className="info-tooltip" data-tooltip={t('settings.push.supports')}>ⓘ</span>
|
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}>ⓘ</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -2418,11 +2727,13 @@ function AppContent() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
<div className="export-import-grid">
|
<div className="setting-group">
|
||||||
{/* Export */}
|
{/* Export */}
|
||||||
<div className="export-import-card">
|
<div className="action-card">
|
||||||
<h3>{t('exportImport.exportTitle')}</h3>
|
<div className="action-card-content">
|
||||||
<p className="export-import-desc">{t('exportImport.exportDesc')}</p>
|
<span className="action-card-title">{t('exportImport.exportTitle')}</span>
|
||||||
|
<span className="action-card-desc">{t('exportImport.exportDesc')}</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="secondary"
|
||||||
@@ -2434,16 +2745,19 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import */}
|
{/* Import */}
|
||||||
<div className="export-import-card">
|
<div className="action-card">
|
||||||
<h3>{t('exportImport.importTitle')}</h3>
|
<div className="action-card-content">
|
||||||
<p className="export-import-desc">{t('exportImport.importDesc')}</p>
|
<span className="action-card-title">{t('exportImport.importTitle')}</span>
|
||||||
<label className="export-import-file-btn">
|
<span className="action-card-desc">{t('exportImport.importDesc')}</span>
|
||||||
|
</div>
|
||||||
|
<label className="btn secondary">
|
||||||
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json,application/json"
|
accept=".json,application/json"
|
||||||
onChange={handleImportFileSelect}
|
onChange={handleImportFileSelect}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
|
style={{display: 'none'}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -2820,6 +3134,33 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Refill History */}
|
||||||
|
{refillHistory.length > 0 && (
|
||||||
|
<div className="med-detail-section">
|
||||||
|
<h3
|
||||||
|
className="section-header-clickable"
|
||||||
|
onClick={() => setRefillHistoryExpanded(!refillHistoryExpanded)}
|
||||||
|
>
|
||||||
|
{t('refill.history')} ({refillHistory.length})
|
||||||
|
<span className="expand-arrow">{refillHistoryExpanded ? '▼' : '▶'}</span>
|
||||||
|
</h3>
|
||||||
|
{refillHistoryExpanded && (
|
||||||
|
<div className="refill-history-list">
|
||||||
|
{refillHistory.map((entry) => (
|
||||||
|
<div key={entry.id} className="refill-history-item">
|
||||||
|
<span className="refill-date">
|
||||||
|
{new Date(entry.refillDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "numeric" })}, {new Date(entry.refillDate).toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
<span className="refill-amount">
|
||||||
|
+{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + entry.loosePillsAdded} {t('common.pills')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="med-detail-footer">
|
<div className="med-detail-footer">
|
||||||
@@ -2827,7 +3168,10 @@ function AppContent() {
|
|||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
</button>
|
</button>
|
||||||
<div className="footer-actions">
|
<div className="footer-actions">
|
||||||
<button className="secondary" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); startEdit(selectedMed); }}>
|
<button className="success" onClick={() => setShowRefillModal(true)}>
|
||||||
|
{t('refill.button')}
|
||||||
|
</button>
|
||||||
|
<button className="info" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); startEdit(selectedMed); }}>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</button>
|
</button>
|
||||||
{selectedMed.blisters.length > 0 && (
|
{selectedMed.blisters.length > 0 && (
|
||||||
@@ -2851,6 +3195,56 @@ function AppContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Refill Modal */}
|
||||||
|
{showRefillModal && (
|
||||||
|
<div className="modal-overlay" onClick={(e) => { e.stopPropagation(); setShowRefillModal(false); }}>
|
||||||
|
<div className="modal-content refill-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close" onClick={() => setShowRefillModal(false)}>×</button>
|
||||||
|
<h2>{t('refill.title')}</h2>
|
||||||
|
<p className="refill-med-name">{selectedMed.name}</p>
|
||||||
|
|
||||||
|
<div className="refill-form">
|
||||||
|
<label>
|
||||||
|
{t('refill.packs')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={refillPacks}
|
||||||
|
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('refill.loosePills')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={refillLoose}
|
||||||
|
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="ghost" onClick={() => setShowRefillModal(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<div className="refill-footer-right">
|
||||||
|
<button
|
||||||
|
className="success"
|
||||||
|
onClick={() => submitRefill(selectedMed.id)}
|
||||||
|
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||||
|
>
|
||||||
|
{refillSaving ? t('common.saving') : t('refill.button')}
|
||||||
|
</button>
|
||||||
|
{(refillPacks > 0 || refillLoose > 0) && (
|
||||||
|
<span className="refill-preview">+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose} {t('common.pills')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2875,7 +3269,7 @@ function AppContent() {
|
|||||||
<div
|
<div
|
||||||
key={med.id}
|
key={med.id}
|
||||||
className="user-med-item clickable"
|
className="user-med-item clickable"
|
||||||
onClick={() => { setSelectedUser(null); setSelectedMed(med); }}
|
onClick={() => { setSelectedUser(null); openMedDetail(med); }}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||||
<div className="user-med-info">
|
<div className="user-med-info">
|
||||||
@@ -2977,6 +3371,19 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Schedule Lightbox - for clicking medication images in schedule */}
|
||||||
|
{scheduleLightboxImage && (
|
||||||
|
<div className="lightbox-overlay" onClick={() => setScheduleLightboxImage(null)}>
|
||||||
|
<button className="lightbox-close" onClick={() => setScheduleLightboxImage(null)}>×</button>
|
||||||
|
<img
|
||||||
|
src={scheduleLightboxImage}
|
||||||
|
alt="Medication"
|
||||||
|
className="lightbox-image"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile Edit Modal */}
|
{/* Mobile Edit Modal */}
|
||||||
{showEditModal && (
|
{showEditModal && (
|
||||||
<div className="modal-overlay" onClick={() => setShowEditModal(false)}>
|
<div className="modal-overlay" onClick={() => setShowEditModal(false)}>
|
||||||
@@ -2984,11 +3391,6 @@ function AppContent() {
|
|||||||
<button className="modal-close" onClick={() => { setShowEditModal(false); resetForm(); }}>×</button>
|
<button className="modal-close" onClick={() => { setShowEditModal(false); resetForm(); }}>×</button>
|
||||||
<div className="edit-modal-header">
|
<div className="edit-modal-header">
|
||||||
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
||||||
{editingId && (
|
|
||||||
<button type="button" className="btn secondary small" onClick={resetForm}>
|
|
||||||
+ {t('form.newEntry')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<form className="form-grid mobile-edit-form" onSubmit={(e) => { saveMedication(e); setShowEditModal(false); }}>
|
<form className="form-grid mobile-edit-form" onSubmit={(e) => { saveMedication(e); setShowEditModal(false); }}>
|
||||||
<label className={`full ${fieldErrors.name ? 'has-error' : ''}`}>
|
<label className={`full ${fieldErrors.name ? 'has-error' : ''}`}>
|
||||||
@@ -3065,6 +3467,45 @@ function AppContent() {
|
|||||||
{t('form.expiryDate')}
|
{t('form.expiryDate')}
|
||||||
<input type="date" value={form.expiryDate} onChange={(e) => setForm({ ...form, expiryDate: e.target.value })} />
|
<input type="date" value={form.expiryDate} onChange={(e) => setForm({ ...form, expiryDate: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Refill section - only shown when editing (mobile) */}
|
||||||
|
{editingId && (
|
||||||
|
<div className="full refill-section">
|
||||||
|
<h4 className="refill-title">{t('refill.title')}</h4>
|
||||||
|
<div className="refill-form-inline">
|
||||||
|
<label>
|
||||||
|
{t('refill.packs')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={refillPacks}
|
||||||
|
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('refill.loosePills')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={refillLoose}
|
||||||
|
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="success"
|
||||||
|
onClick={() => submitRefill(editingId)}
|
||||||
|
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||||
|
>
|
||||||
|
{refillSaving ? t('common.saving') : t('refill.button')}
|
||||||
|
</button>
|
||||||
|
{(refillPacks > 0 || refillLoose > 0) && (
|
||||||
|
<span className="refill-preview">+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
|
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
|
||||||
{t('form.notes')}
|
{t('form.notes')}
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -173,7 +180,9 @@
|
|||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"supports": "Unterstützt ntfy, Discord, Telegram, Slack"
|
"urlPlaceholder": "ntfy://topic oder pushover://:token@userkey/",
|
||||||
|
"supports": "Unterstützt ntfy, Pushover, Gotify, Discord, Telegram, Slack & mehr",
|
||||||
|
"docsLink": "Siehe shoutrrr.dev für alle Services"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Erinnerungsplan",
|
"title": "Erinnerungsplan",
|
||||||
@@ -217,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",
|
||||||
@@ -374,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",
|
||||||
@@ -175,7 +182,9 @@
|
|||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"supports": "Supports ntfy, Discord, Telegram, Slack"
|
"urlPlaceholder": "ntfy://topic or pushover://:token@userkey/",
|
||||||
|
"supports": "Supports ntfy, Pushover, Gotify, Discord, Telegram, Slack & more",
|
||||||
|
"docsLink": "See shoutrrr.dev for all services"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Reminder Schedule",
|
"title": "Reminder Schedule",
|
||||||
@@ -376,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+413
-75
@@ -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;
|
||||||
@@ -690,6 +730,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 +1428,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 +1711,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;
|
||||||
@@ -2061,10 +2169,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 +2366,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 +2970,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 +3025,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 +3392,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 +3836,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 +4335,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user