From 82b2be48cd7d86ceca7a0544b0b182f2f6840bb4 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 17 Jan 2026 20:39:18 +0100 Subject: [PATCH] feat: Add Medication Refill feature with mobile UI improvements (#30) * feat: Add Medication Refill feature with UI improvements - Add refill functionality to medications (add packs/loose pills) - Add refill API endpoint with history tracking - Add refill section in edit forms (desktop & mobile) - Add refill modal in medication detail view - Add refill history display with expand/collapse - Add schedule lightbox for clicking medication images - Improve button styling with primary/info/success classes - Move '+ New entry' button to medication list header - Lightbox size: 50% desktop, 90% mobile - Update selectedMed sync after stock changes - Migrate from schema-sql.ts to Drizzle Kit migrations * fix: Improve mobile tooltips and refill modal layout - Center tooltips on screen for mobile devices (fixed position) - Close tooltips automatically when scrolling on touch devices - Use click-based tooltip activation instead of hover on mobile - Fix refill modal buttons to display in two rows on mobile --- .github/copilot-instructions.md | 60 +- backend/drizzle.config.ts | 10 + backend/drizzle/0000_init.sql | 112 +++ backend/drizzle/meta/0000_snapshot.json | 819 ++++++++++++++++++ backend/drizzle/meta/_journal.json | 13 + backend/package-lock.json | 1028 ++++++++++++++++++++++- backend/package.json | 3 +- backend/src/db/client.ts | 51 +- backend/src/db/migrate.ts | 54 +- backend/src/db/schema-sql.ts | 10 + backend/src/db/schema.ts | 13 + backend/src/index.ts | 3 + backend/src/routes/refills.ts | 124 +++ backend/src/test/database.test.ts | 662 +++++---------- backend/src/test/e2e-routes.test.ts | 353 ++++++++ backend/src/test/refills.test.ts | 394 +++++++++ backend/src/test/setup.ts | 19 +- frontend/src/App.tsx | 400 ++++++++- frontend/src/i18n/de.json | 22 +- frontend/src/i18n/en.json | 20 +- frontend/src/styles.css | 459 ++++++++-- 21 files changed, 3963 insertions(+), 666 deletions(-) create mode 100644 backend/drizzle.config.ts create mode 100644 backend/drizzle/0000_init.sql create mode 100644 backend/drizzle/meta/0000_snapshot.json create mode 100644 backend/drizzle/meta/_journal.json create mode 100644 backend/src/routes/refills.ts create mode 100644 backend/src/test/refills.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1cadd0c..ecd8aaa 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -455,40 +455,50 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp > Users upgrade their Docker containers but keep their existing DB. > The app must NOT crash if old columns are missing. +### Schema Management with Drizzle Kit + +The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**: + +- **`backend/src/db/schema.ts`** - Drizzle ORM schema definitions (TypeScript) +- **`backend/drizzle/`** - Generated SQL migrations (auto-generated from schema.ts) + +**DO NOT manually edit migration files!** They are generated from schema.ts. + +### Adding New Columns + +1. **Add to schema.ts** with DEFAULT value: + ```typescript + maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5), + ``` + +2. **Generate migration**: + ```bash + cd backend && npx drizzle-kit generate --name add_column_name + ``` + +3. **Add backward-compatible ALTER migration** in `client.ts` `runAlterMigrations()`: + ```typescript + `ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`, + ``` + +4. **NULL-safe reading** in routes: + ```typescript + maxNaggingReminders: settings.maxNaggingReminders ?? 5, + ``` + ### Rules for New Columns 1. **ALWAYS with DEFAULT value**: New columns must have `NOT NULL DEFAULT ` 2. **NULL-safe in code**: All queries must use `?? defaultValue` or `?? false` -3. **Update schema SQL**: Add to these files: - - `backend/src/db/schema.ts` - Drizzle Schema - - `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` for new DBs - - `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` migration -4. **Update test schemas**: All test files with their own schema: - - `backend/src/test/e2e-routes.test.ts` - - `backend/src/test/integration.test.ts` - - `backend/src/test/planner.test.ts` - -### Example: Adding a New Column - -```typescript -// 1. schema.ts - Drizzle definition -maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5), - -// 2. schema-sql.ts - For new databases -"max_nagging_reminders integer NOT NULL DEFAULT 5," - -// 3. client.ts - Migration for existing DBs (IN ensureTablesExist()) -await client.execute(`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`).catch(() => {}); - -// 4. Routes - NULL-safe reading -maxNaggingReminders: settings.maxNaggingReminders ?? 5, -``` +3. **Generate migration**: Run `npx drizzle-kit generate` after schema changes +4. **Add ALTER migration**: For backward compatibility with existing DBs ### What is NOT Allowed - ❌ Deleting or renaming columns (breaks old DBs) - ❌ `NOT NULL` without `DEFAULT` (INSERT fails) - ❌ Reading columns without fallback in code +- ❌ Manually editing migration SQL files - ❌ Documenting "delete DB" as a solution ### When Backward Compatibility is NOT Possible @@ -504,6 +514,8 @@ If a breaking change is unavoidable: |---------|----------| | Backend entry | `backend/src/index.ts` | | Database schema | `backend/src/db/schema.ts` | +| Drizzle migrations | `backend/drizzle/*.sql` | +| Drizzle config | `backend/drizzle.config.ts` | | Backend routes | `backend/src/routes/*.ts` | | Backend services | `backend/src/services/*.ts` | | Frontend app | `frontend/src/App.tsx` | diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts new file mode 100644 index 0000000..31054b1 --- /dev/null +++ b/backend/drizzle.config.ts @@ -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", + }, +}); diff --git a/backend/drizzle/0000_init.sql b/backend/drizzle/0000_init.sql new file mode 100644 index 0000000..3b7fa47 --- /dev/null +++ b/backend/drizzle/0000_init.sql @@ -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`); \ No newline at end of file diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..8d7301f --- /dev/null +++ b/backend/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..d58961f --- /dev/null +++ b/backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1768600500759, + "tag": "0000_init", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 04a93ab..4355a3c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-backend", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-backend", - "version": "1.0.2", + "version": "1.1.0", "dependencies": { "@fastify/cookie": "^10.0.1", "@fastify/cors": "^10.0.1", @@ -19,7 +19,7 @@ "@libsql/client": "^0.10.0", "argon2": "^0.40.0", "dotenv": "^16.4.5", - "drizzle-orm": "^0.32.2", + "drizzle-orm": "^0.45.1", "fastify": "^5.0.0", "nodemailer": "^7.0.11", "openid-client": "^6.8.1", @@ -30,6 +30,7 @@ "@types/nodemailer": "^6.4.21", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^4.0.16", + "drizzle-kit": "^0.31.8", "supertest": "^7.0.0", "tsx": "^4.19.0", "typescript": "^5.5.4", @@ -784,6 +785,449 @@ "node": ">=18" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -3171,6 +3615,13 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3404,37 +3855,538 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-kit": { + "version": "0.31.8", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz", + "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/drizzle-orm": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.32.2.tgz", - "integrity": "sha512-3fXKzPzrgZIcnWCSLiERKN5Opf9Iagrag75snfFlKeKSYB1nlgPBshzW3Zn6dQymkyiib+xc4nIz0t8U+Xdpuw==", + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=3", - "@electric-sql/pglite": ">=0.1.1", - "@libsql/client": "*", - "@neondatabase/serverless": ">=0.1", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1", + "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", - "@types/react": ">=18", "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", - "expo-sqlite": ">=13.2.0", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", - "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, @@ -3451,6 +4403,9 @@ "@libsql/client": { "optional": true }, + "@libsql/client-wasm": { + "optional": true + }, "@neondatabase/serverless": { "optional": true }, @@ -3475,10 +4430,10 @@ "@types/pg": { "optional": true }, - "@types/react": { + "@types/sql.js": { "optional": true }, - "@types/sql.js": { + "@upstash/redis": { "optional": true }, "@vercel/postgres": { @@ -3496,6 +4451,9 @@ "expo-sqlite": { "optional": true }, + "gel": { + "optional": true + }, "knex": { "optional": true }, @@ -3514,9 +4472,6 @@ "prisma": { "optional": true }, - "react": { - "optional": true - }, "sql.js": { "optional": true }, @@ -3624,6 +4579,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3659,6 +4615,19 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -5239,6 +6208,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5249,6 +6228,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index eeecb77..ca539fb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,7 @@ "@libsql/client": "^0.10.0", "argon2": "^0.40.0", "dotenv": "^16.4.5", - "drizzle-orm": "^0.32.2", + "drizzle-orm": "^0.45.1", "fastify": "^5.0.0", "nodemailer": "^7.0.11", "openid-client": "^6.8.1", @@ -35,6 +35,7 @@ "@types/nodemailer": "^6.4.21", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^4.0.16", + "drizzle-kit": "^0.31.8", "supertest": "^7.0.0", "tsx": "^4.19.0", "typescript": "^5.5.4", diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 55c275c..251f23f 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -1,12 +1,18 @@ import { createClient, Client } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs"; -import { resolve } from "path"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; import dotenv from "dotenv"; -import { getTableCreationSQL } from "./schema-sql.js"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); +// Get migrations folder path (relative to this file's location) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + // ============================================================================= // Exported utility functions for testing // ============================================================================= @@ -44,23 +50,20 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error? } } -/** Get the SQL statements for creating all tables (re-exported from schema-sql) */ -export { getTableCreationSQL } from "./schema-sql.js"; +/** Run drizzle-kit migrations on the database */ +export async function runDrizzleMigrations(database: ReturnType): Promise<{ success: boolean; error?: string }> { + try { + await migrate(database, { migrationsFolder }); + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message }; + } +} -/** Run table creation migrations on a client */ -export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> { - const tableCreations = getTableCreationSQL(); +/** Run ALTER TABLE migrations for backward compatibility with older databases */ +export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> { const errors: string[] = []; - for (const sql of tableCreations) { - try { - await client.execute(sql); - } catch (e: any) { - errors.push(e.message); - } - } - - // Run ALTER TABLE migrations for backward compatibility with older databases // These add new columns to existing tables (silently fail if column already exists) const alterMigrations = [ // Added in v1.x - repeat reminders and nagging settings @@ -149,9 +152,19 @@ export const db = drizzle(client); // Auto-run migrations (self-healing database) async function runMigrations() { - const result = await runTableMigrations(client); - if (result.errors.length > 0) { - result.errors.forEach(err => console.error(`[DB] Table creation error:`, err)); + // Run drizzle-kit generated migrations + console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`); + const migrateResult = await runDrizzleMigrations(db); + if (!migrateResult.success) { + console.error(`[DB] Migration error:`, migrateResult.error); + } else { + console.log(`[DB] Drizzle migrations completed`); + } + + // Run ALTER TABLE migrations for backward compatibility + const alterResult = await runAlterMigrations(client); + if (alterResult.errors.length > 0) { + alterResult.errors.forEach(err => console.error(`[DB] ALTER migration error:`, err)); } console.log(`[DB] Tables verified/created`); diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 1b5862f..ed0a8ac 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,39 +1,45 @@ import { createClient, Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; import dotenv from "dotenv"; -import fs from "fs"; -import path from "path"; -import { getTableCreationSQL } from "./schema-sql.js"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); +// Get migrations folder path (relative to this file's location) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + // ============================================================================= // Exported utility functions for testing // ============================================================================= -/** Get the full migration SQL string (re-exported from schema-sql) */ -export { getTableCreationSQL }; - -/** Split SQL string into individual statements */ +/** Split SQL string into individual statements (for backwards compatibility with tests) */ export function splitSQLStatements(sql: string): string[] { return sql.split(';').filter(s => s.trim().length > 0); } -/** Execute migration statements on a client */ +/** Execute drizzle migrations on a database */ export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> { - const statements = getTableCreationSQL(); const errors: string[] = []; - let executed = 0; + const db = drizzle(client); - for (const stmt of statements) { - try { - await client.execute(stmt); - executed++; - } catch (err: any) { - errors.push(err.message); - } + try { + await migrate(db, { migrationsFolder }); + + // Count tables as a proxy for "executed" statements + const tables = await client.execute( + "SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'" + ); + const executed = Number(tables.rows[0].count) || 0; + + return { success: true, executed, errors }; + } catch (err: any) { + errors.push(err.message); + return { success: false, executed: 0, errors }; } - - return { success: errors.length === 0, executed, errors }; } /** Get a preview of statement (first N characters) */ @@ -54,15 +60,13 @@ const url = "file:./data/medassist-ng.db"; async function main() { console.log("Starting database setup..."); console.log("Database URL:", url); + console.log("Migrations folder:", migrationsFolder); const client = createClient({ url }); + const db = drizzle(client); - const statements = getTableCreationSQL(); - - for (const stmt of statements) { - console.log("Executing:", getStatementPreview(stmt)); - await client.execute(stmt); - } + console.log("Running drizzle migrations..."); + await migrate(db, { migrationsFolder }); console.log("Database setup complete!"); process.exit(0); diff --git a/backend/src/db/schema-sql.ts b/backend/src/db/schema-sql.ts index ab4b461..81d7692 100644 --- a/backend/src/db/schema-sql.ts +++ b/backend/src/db/schema-sql.ts @@ -100,5 +100,15 @@ export function getTableCreationSQL(): string[] { dismissed integer NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, + `CREATE TABLE IF NOT EXISTS refill_history ( + id integer PRIMARY KEY AUTOINCREMENT, + medication_id integer NOT NULL, + user_id integer NOT NULL, + packs_added integer NOT NULL DEFAULT 0, + loose_pills_added integer NOT NULL DEFAULT 0, + refill_date integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, ]; } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 03bccc5..f282651 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -68,6 +68,7 @@ export const userSettings = sqliteTable("user_settings", { lowStockDays: integer("low_stock_days").notNull().default(30), normalStockDays: integer("normal_stock_days").notNull().default(90), highStockDays: integer("high_stock_days").notNull().default(180), + expiryWarningDays: integer("expiry_warning_days").notNull().default(90), // UI preferences language: text("language", { length: 10 }).notNull().default("en"), // Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses) @@ -117,3 +118,15 @@ export const doseTracking = sqliteTable("dose_tracking", { markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking }); + +// ============================================================================= +// Refill History - Tracks when medication stock was refilled +// ============================================================================= +export const refillHistory = sqliteTable("refill_history", { + id: integer("id").primaryKey({ autoIncrement: true }), + medicationId: integer("medication_id").notNull().references(() => medications.id, { onDelete: "cascade" }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + packsAdded: integer("packs_added").notNull().default(0), + loosePillsAdded: integer("loose_pills_added").notNull().default(0), + refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`), +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 59732cc..7904688 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,7 @@ import { plannerRoutes } from "./routes/planner.js"; import { shareRoutes } from "./routes/share.js"; import { doseRoutes } from "./routes/doses.js"; import { exportRoutes } from "./routes/export.js"; +import { refillRoutes } from "./routes/refills.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; @@ -115,6 +116,7 @@ export async function createApp(options?: { await app.register(shareRoutes); await app.register(doseRoutes); await app.register(exportRoutes); + await app.register(refillRoutes); return app; } @@ -184,6 +186,7 @@ await app.register(plannerRoutes); await app.register(shareRoutes); await app.register(doseRoutes); await app.register(exportRoutes); +await app.register(refillRoutes); const start = async () => { try { diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts new file mode 100644 index 0000000..857fbeb --- /dev/null +++ b/backend/src/routes/refills.ts @@ -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 { + 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, + })); + }); +} diff --git a/backend/src/test/database.test.ts b/backend/src/test/database.test.ts index 5fd8c27..fcecfa0 100644 --- a/backend/src/test/database.test.ts +++ b/backend/src/test/database.test.ts @@ -1,45 +1,78 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; import { mkdirSync, rmSync, existsSync } from "fs"; -import { resolve } from "path"; +import { resolve, dirname } from "path"; import { tmpdir } from "os"; +import { fileURLToPath } from "url"; // Import the exported utility functions from client.ts import { buildDbUrl, getDbPaths, ensureDataDirectory, - getTableCreationSQL, - runTableMigrations, + runDrizzleMigrations, + runAlterMigrations, ensureDefaultUser, } from "../db/client.js"; // Import the exported utility functions from migrate.ts import { - getTableCreationSQL as getTableCreationSQLFromMigrate, splitSQLStatements, executeMigration, getStatementPreview, } from "../db/migrate.js"; +// Get migrations folder path +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); + describe("Migration Script Utilities", () => { - describe("getTableCreationSQL", () => { - it("should return a non-empty array of SQL statements", () => { - const statements = getTableCreationSQL(); - expect(Array.isArray(statements)).toBe(true); - expect(statements.length).toBeGreaterThan(0); + describe("executeMigration", () => { + let client: ReturnType; + + beforeEach(() => { + client = createClient({ url: ":memory:" }); }); - it("should contain all table definitions", () => { - const statements = getTableCreationSQL(); - const allSQL = statements.join(" "); - expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users"); - expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications"); - expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS user_settings"); - expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens"); - expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS share_tokens"); - expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking"); + it("should execute all migrations successfully", async () => { + const result = await executeMigration(client); + expect(result.success).toBe(true); + expect(result.executed).toBeGreaterThan(0); + expect(result.errors).toHaveLength(0); + }); + + it("should create all tables", async () => { + await executeMigration(client); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name" + ); + + const tableNames = tables.rows.map(r => r.name); + expect(tableNames).toContain("users"); + expect(tableNames).toContain("medications"); + expect(tableNames).toContain("user_settings"); + expect(tableNames).toContain("refresh_tokens"); + expect(tableNames).toContain("share_tokens"); + expect(tableNames).toContain("dose_tracking"); + expect(tableNames).toContain("refill_history"); + }); + + it("should be idempotent", async () => { + await executeMigration(client); + const result = await executeMigration(client); + expect(result.success).toBe(true); + }); + + it("should allow inserting data after migration", async () => { + await executeMigration(client); + + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + const result = await client.execute("SELECT * FROM users"); + expect(result.rows).toHaveLength(1); }); }); @@ -62,11 +95,6 @@ describe("Migration Script Utilities", () => { expect(statements).toHaveLength(2); }); - it("should handle getTableCreationSQL output correctly", () => { - const statements = getTableCreationSQL(); - expect(statements).toHaveLength(6); - }); - it("should preserve whitespace within statements", () => { const sql = "CREATE TABLE test (\n id INTEGER\n);"; const statements = splitSQLStatements(sql); @@ -103,52 +131,6 @@ describe("Migration Script Utilities", () => { expect(preview).toBe("CREATE TABLE IF NOT EXISTS use..."); }); }); - - describe("executeMigration", () => { - let client: ReturnType; - - beforeEach(() => { - client = createClient({ url: ":memory:" }); - }); - - it("should execute all migrations successfully", async () => { - const result = await executeMigration(client); - expect(result.success).toBe(true); - expect(result.executed).toBe(6); - expect(result.errors).toHaveLength(0); - }); - - it("should create all tables", async () => { - await executeMigration(client); - - const tables = await client.execute( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" - ); - - const tableNames = tables.rows.map(r => r.name); - expect(tableNames).toContain("users"); - expect(tableNames).toContain("medications"); - expect(tableNames).toContain("user_settings"); - expect(tableNames).toContain("refresh_tokens"); - expect(tableNames).toContain("share_tokens"); - expect(tableNames).toContain("dose_tracking"); - }); - - it("should be idempotent", async () => { - await executeMigration(client); - const result = await executeMigration(client); - expect(result.success).toBe(true); - expect(result.executed).toBe(6); - }); - - it("should allow inserting data after migration", async () => { - await executeMigration(client); - - await client.execute("INSERT INTO users (username) VALUES ('testuser')"); - const result = await client.execute("SELECT * FROM users"); - expect(result.rows).toHaveLength(1); - }); - }); }); describe("Database Client Utilities", () => { @@ -218,63 +200,7 @@ describe("Database Client Utilities", () => { }); }); - describe("getTableCreationSQL", () => { - it("should return array of SQL statements", () => { - const statements = getTableCreationSQL(); - expect(Array.isArray(statements)).toBe(true); - expect(statements.length).toBe(6); - }); - - it("should include users table", () => { - const statements = getTableCreationSQL(); - const usersSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS users")); - expect(usersSQL).toBeDefined(); - expect(usersSQL).toContain("username text NOT NULL UNIQUE"); - expect(usersSQL).toContain("password_hash text"); - expect(usersSQL).toContain("auth_provider text NOT NULL DEFAULT 'local'"); - }); - - it("should include medications table", () => { - const statements = getTableCreationSQL(); - const medsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS medications")); - expect(medsSQL).toBeDefined(); - expect(medsSQL).toContain("user_id integer NOT NULL"); - expect(medsSQL).toContain("taken_by_json text NOT NULL DEFAULT '[]'"); - expect(medsSQL).toContain("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"); - }); - - it("should include user_settings table", () => { - const statements = getTableCreationSQL(); - const settingsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS user_settings")); - expect(settingsSQL).toBeDefined(); - expect(settingsSQL).toContain("email_enabled integer NOT NULL DEFAULT 0"); - expect(settingsSQL).toContain("language text NOT NULL DEFAULT 'en'"); - }); - - it("should include refresh_tokens table", () => { - const statements = getTableCreationSQL(); - const tokensSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS refresh_tokens")); - expect(tokensSQL).toBeDefined(); - expect(tokensSQL).toContain("token_id text NOT NULL UNIQUE"); - }); - - it("should include share_tokens table", () => { - const statements = getTableCreationSQL(); - const shareSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS share_tokens")); - expect(shareSQL).toBeDefined(); - expect(shareSQL).toContain("taken_by text NOT NULL"); - }); - - it("should include dose_tracking table", () => { - const statements = getTableCreationSQL(); - const doseSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS dose_tracking")); - expect(doseSQL).toBeDefined(); - expect(doseSQL).toContain("dose_id text NOT NULL"); - expect(doseSQL).toContain("marked_by text"); - }); - }); - - describe("runTableMigrations", () => { + describe("runDrizzleMigrations", () => { let client: ReturnType; beforeEach(() => { @@ -282,23 +208,24 @@ describe("Database Client Utilities", () => { }); it("should create all tables successfully", async () => { - const result = await runTableMigrations(client); + const db = drizzle(client); + const result = await runDrizzleMigrations(db); expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); }); it("should be idempotent (run twice without errors)", async () => { - await runTableMigrations(client); - const result = await runTableMigrations(client); + const db = drizzle(client); + await runDrizzleMigrations(db); + const result = await runDrizzleMigrations(db); expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); }); - it("should create all 6 tables", async () => { - await runTableMigrations(client); + it("should create all 7 tables", async () => { + const db = drizzle(client); + await runDrizzleMigrations(db); const tables = await client.execute( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name" ); const tableNames = tables.rows.map(r => r.name); @@ -308,6 +235,29 @@ describe("Database Client Utilities", () => { expect(tableNames).toContain("refresh_tokens"); expect(tableNames).toContain("share_tokens"); expect(tableNames).toContain("dose_tracking"); + expect(tableNames).toContain("refill_history"); + }); + }); + + describe("runAlterMigrations", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); + }); + + it("should run without errors on a fresh database", async () => { + const result = await runAlterMigrations(client); + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should be idempotent", async () => { + await runAlterMigrations(client); + const result = await runAlterMigrations(client); + expect(result.success).toBe(true); }); }); @@ -316,7 +266,8 @@ describe("Database Client Utilities", () => { beforeEach(async () => { client = createClient({ url: ":memory:" }); - await runTableMigrations(client); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); }); it("should create default user when auth is disabled", async () => { @@ -386,246 +337,83 @@ describe("Database Client", () => { }); }); - describe("Table Schema Creation", () => { + describe("Table Schema via Drizzle Migrations", () => { let client: ReturnType; beforeEach(async () => { client = createClient({ url: ":memory:" }); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); }); - it("should create users table", async () => { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - password_hash text, - avatar_url text, - auth_provider text NOT NULL DEFAULT 'local', - oidc_subject text, - is_active integer NOT NULL DEFAULT 1, - last_login_at integer, - created_at integer NOT NULL DEFAULT (strftime('%s','now')), - updated_at integer NOT NULL DEFAULT (strftime('%s','now')) - ) - `); - - // Verify table exists - const tables = await client.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='users'" - ); - expect(tables.rows).toHaveLength(1); - }); - - it("should create medications table with foreign key", async () => { - // First create users table - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); - - await client.execute(` - CREATE TABLE IF NOT EXISTS medications ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - name text NOT NULL, - generic_name text, - taken_by_json text NOT NULL DEFAULT '[]', - pack_count integer NOT NULL DEFAULT 1, - blisters_per_pack integer NOT NULL DEFAULT 1, - pills_per_blister integer NOT NULL DEFAULT 1, - loose_tablets integer NOT NULL DEFAULT 0, - pill_weight_mg integer, - usage_json text NOT NULL DEFAULT '[]', - every_json text NOT NULL DEFAULT '[]', - start_json text NOT NULL DEFAULT '[]', - image_url text, - expiry_date text, - notes text, - intake_reminders_enabled integer NOT NULL DEFAULT 0, - updated_at integer NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - - const tables = await client.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='medications'" - ); - expect(tables.rows).toHaveLength(1); - }); - - it("should create user_settings table", async () => { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); - - await client.execute(` - CREATE TABLE IF NOT EXISTS user_settings ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL UNIQUE, - email_enabled integer NOT NULL DEFAULT 0, - notification_email text, - email_stock_reminders integer NOT NULL DEFAULT 1, - email_intake_reminders integer NOT NULL DEFAULT 1, - shoutrrr_enabled integer NOT NULL DEFAULT 0, - shoutrrr_url text, - shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, - shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, - reminder_days_before integer NOT NULL DEFAULT 7, - repeat_daily_reminders integer NOT NULL DEFAULT 0, - low_stock_days integer NOT NULL DEFAULT 30, - normal_stock_days integer NOT NULL DEFAULT 90, - high_stock_days integer NOT NULL DEFAULT 180, - expiry_warning_days integer NOT NULL DEFAULT 90, - language text NOT NULL DEFAULT 'en', - stock_calculation_mode text NOT NULL DEFAULT 'automatic', - last_auto_email_sent text, - last_notification_type text, - last_notification_channel text, - updated_at integer NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - - const tables = await client.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'" - ); - expect(tables.rows).toHaveLength(1); - }); - - it("should create refresh_tokens table", async () => { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); - - await client.execute(` - CREATE TABLE IF NOT EXISTS refresh_tokens ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - token_id text NOT NULL UNIQUE, - expires_at integer NOT NULL, - rotated_at integer, - revoked integer NOT NULL DEFAULT 0, - created_at integer NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - - const tables = await client.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'" - ); - expect(tables.rows).toHaveLength(1); - }); - - it("should create share_tokens table", async () => { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); - - await client.execute(` - CREATE TABLE IF NOT EXISTS share_tokens ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - token text NOT NULL UNIQUE, - taken_by text NOT NULL, - schedule_days integer NOT NULL DEFAULT 30, - created_at integer NOT NULL DEFAULT (strftime('%s','now')), - expires_at integer, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - - const tables = await client.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='share_tokens'" - ); - expect(tables.rows).toHaveLength(1); - }); - - it("should create dose_tracking table", async () => { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); - - await client.execute(` - CREATE TABLE IF NOT EXISTS dose_tracking ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - dose_id text NOT NULL, - taken_at integer NOT NULL DEFAULT (strftime('%s','now')), - marked_by text, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - - const tables = await client.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='dose_tracking'" - ); - expect(tables.rows).toHaveLength(1); - }); - - it("should enforce unique constraint on username", async () => { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); - - await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + it("should have users table with correct columns", async () => { + const columns = await client.execute("PRAGMA table_info(users)"); + const columnNames = columns.rows.map(r => r.name); - await expect( - client.execute("INSERT INTO users (username) VALUES ('testuser')") - ).rejects.toThrow(); + expect(columnNames).toContain("id"); + expect(columnNames).toContain("username"); + expect(columnNames).toContain("password_hash"); + expect(columnNames).toContain("auth_provider"); }); - it("should enforce unique constraint on refresh token_id", async () => { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); - await client.execute("INSERT INTO users (username) VALUES ('testuser')"); - - await client.execute(` - CREATE TABLE IF NOT EXISTS refresh_tokens ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - token_id text NOT NULL UNIQUE, - expires_at integer NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - - await client.execute( - "INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)" - ); + it("should have medications table with correct columns", async () => { + const columns = await client.execute("PRAGMA table_info(medications)"); + const columnNames = columns.rows.map(r => r.name); - await expect( - client.execute( - "INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)" - ) - ).rejects.toThrow(); + expect(columnNames).toContain("id"); + expect(columnNames).toContain("user_id"); + expect(columnNames).toContain("name"); + expect(columnNames).toContain("taken_by_json"); + expect(columnNames).toContain("pack_count"); + expect(columnNames).toContain("usage_json"); + }); + + it("should have user_settings table with correct columns", async () => { + const columns = await client.execute("PRAGMA table_info(user_settings)"); + const columnNames = columns.rows.map(r => r.name); + + expect(columnNames).toContain("id"); + expect(columnNames).toContain("user_id"); + expect(columnNames).toContain("email_enabled"); + expect(columnNames).toContain("language"); + expect(columnNames).toContain("stock_calculation_mode"); + }); + + it("should have refresh_tokens table", async () => { + const columns = await client.execute("PRAGMA table_info(refresh_tokens)"); + const columnNames = columns.rows.map(r => r.name); + + expect(columnNames).toContain("id"); + expect(columnNames).toContain("user_id"); + expect(columnNames).toContain("token_id"); + }); + + it("should have share_tokens table", async () => { + const columns = await client.execute("PRAGMA table_info(share_tokens)"); + const columnNames = columns.rows.map(r => r.name); + + expect(columnNames).toContain("id"); + expect(columnNames).toContain("token"); + expect(columnNames).toContain("taken_by"); + }); + + it("should have dose_tracking table", async () => { + const columns = await client.execute("PRAGMA table_info(dose_tracking)"); + const columnNames = columns.rows.map(r => r.name); + + expect(columnNames).toContain("id"); + expect(columnNames).toContain("dose_id"); + expect(columnNames).toContain("marked_by"); + }); + + it("should have refill_history table", async () => { + const columns = await client.execute("PRAGMA table_info(refill_history)"); + const columnNames = columns.rows.map(r => r.name); + + expect(columnNames).toContain("id"); + expect(columnNames).toContain("medication_id"); + expect(columnNames).toContain("packs_added"); + expect(columnNames).toContain("loose_pills_added"); }); }); @@ -634,15 +422,8 @@ describe("Database Client", () => { beforeEach(async () => { client = createClient({ url: ":memory:" }); - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local', - is_active integer NOT NULL DEFAULT 1, - created_at integer NOT NULL DEFAULT (strftime('%s','now')) - ) - `); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); }); it("should use default values for auth_provider", async () => { @@ -656,16 +437,8 @@ describe("Database Client", () => { await client.execute("INSERT INTO users (username) VALUES ('testuser')"); const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'"); - expect(result.rows[0].is_active).toBe(1); - }); - - it("should generate created_at timestamp", async () => { - await client.execute("INSERT INTO users (username) VALUES ('testuser')"); - - const result = await client.execute("SELECT created_at FROM users WHERE username = 'testuser'"); - expect(typeof result.rows[0].created_at).toBe("number"); - // Should be a reasonable Unix timestamp (after year 2020) - expect(Number(result.rows[0].created_at)).toBeGreaterThan(1577836800); + // SQLite stores booleans as integers + expect(result.rows[0].is_active).toBeTruthy(); }); }); @@ -674,40 +447,18 @@ describe("Database Client", () => { beforeEach(async () => { client = createClient({ url: ":memory:" }); - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); await client.execute("INSERT INTO users (username) VALUES ('testuser')"); - - await client.execute(` - CREATE TABLE IF NOT EXISTS user_settings ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL UNIQUE, - email_enabled integer NOT NULL DEFAULT 0, - shoutrrr_enabled integer NOT NULL DEFAULT 0, - reminder_days_before integer NOT NULL DEFAULT 7, - repeat_daily_reminders integer NOT NULL DEFAULT 0, - low_stock_days integer NOT NULL DEFAULT 30, - normal_stock_days integer NOT NULL DEFAULT 90, - high_stock_days integer NOT NULL DEFAULT 180, - expiry_warning_days integer NOT NULL DEFAULT 90, - language text NOT NULL DEFAULT 'en', - stock_calculation_mode text NOT NULL DEFAULT 'automatic', - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); }); it("should use default notification settings", async () => { await client.execute("INSERT INTO user_settings (user_id) VALUES (1)"); const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1"); - expect(result.rows[0].email_enabled).toBe(0); - expect(result.rows[0].shoutrrr_enabled).toBe(0); + // SQLite stores booleans as integers (false = 0) + expect(result.rows[0].email_enabled).toBeFalsy(); + expect(result.rows[0].shoutrrr_enabled).toBeFalsy(); }); it("should use default stock threshold settings", async () => { @@ -717,7 +468,6 @@ describe("Database Client", () => { expect(result.rows[0].low_stock_days).toBe(30); expect(result.rows[0].normal_stock_days).toBe(90); expect(result.rows[0].high_stock_days).toBe(180); - expect(result.rows[0].expiry_warning_days).toBe(90); }); it("should use default language (en)", async () => { @@ -747,32 +497,9 @@ describe("Database Client", () => { beforeEach(async () => { client = createClient({ url: ":memory:" }); - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); await client.execute("INSERT INTO users (username) VALUES ('testuser')"); - - await client.execute(` - CREATE TABLE IF NOT EXISTS medications ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - name text NOT NULL, - taken_by_json text NOT NULL DEFAULT '[]', - pack_count integer NOT NULL DEFAULT 1, - blisters_per_pack integer NOT NULL DEFAULT 1, - pills_per_blister integer NOT NULL DEFAULT 1, - loose_tablets integer NOT NULL DEFAULT 0, - usage_json text NOT NULL DEFAULT '[]', - every_json text NOT NULL DEFAULT '[]', - start_json text NOT NULL DEFAULT '[]', - intake_reminders_enabled integer NOT NULL DEFAULT 0, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); }); it("should use default inventory values", async () => { @@ -795,11 +522,11 @@ describe("Database Client", () => { expect(result.rows[0].start_json).toBe("[]"); }); - it("should default intake_reminders_enabled to false (0)", async () => { + it("should default intake_reminders_enabled to false", async () => { await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')"); const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'"); - expect(result.rows[0].intake_reminders_enabled).toBe(0); + expect(result.rows[0].intake_reminders_enabled).toBeFalsy(); }); }); @@ -810,21 +537,8 @@ describe("Database Client", () => { client = createClient({ url: ":memory:" }); // Enable foreign keys await client.execute("PRAGMA foreign_keys = ON"); - - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE - ) - `); - await client.execute(` - CREATE TABLE IF NOT EXISTS medications ( - id integer PRIMARY KEY AUTOINCREMENT, - user_id integer NOT NULL, - name text NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); }); it("should cascade delete medications when user is deleted", async () => { @@ -845,18 +559,44 @@ describe("Database Client", () => { }); }); + describe("Unique Constraints", () => { + let client: ReturnType; + + beforeEach(async () => { + client = createClient({ url: ":memory:" }); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); + }); + + it("should enforce unique constraint on username", async () => { + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + + await expect( + client.execute("INSERT INTO users (username) VALUES ('testuser')") + ).rejects.toThrow(); + }); + + it("should enforce unique constraint on refresh token_id", async () => { + await client.execute("INSERT INTO users (username) VALUES ('testuser')"); + await client.execute( + "INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)" + ); + + await expect( + client.execute( + "INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)" + ) + ).rejects.toThrow(); + }); + }); + describe("Default User Creation (Auth Disabled)", () => { let client: ReturnType; beforeEach(async () => { client = createClient({ url: ":memory:" }); - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id integer PRIMARY KEY AUTOINCREMENT, - username text NOT NULL UNIQUE, - auth_provider text NOT NULL DEFAULT 'local' - ) - `); + const db = drizzle(client); + await migrate(db, { migrationsFolder }); }); it("should be able to create a default user with ID 1", async () => { diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 0e21628..9ab7412 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -54,6 +54,8 @@ const { shareRoutes } = await import("../routes/share.js"); const { medicationRoutes } = await import("../routes/medications.js"); const { settingsRoutes } = await import("../routes/settings.js"); const { healthRoutes } = await import("../routes/health.js"); +const { refillRoutes } = await import("../routes/refills.js"); +const { exportRoutes } = await import("../routes/export.js"); // ============================================================================= // Test Setup @@ -142,6 +144,16 @@ async function createSchema(client: Client) { dismissed integer NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, + `CREATE TABLE IF NOT EXISTS refill_history ( + id integer PRIMARY KEY AUTOINCREMENT, + medication_id integer NOT NULL, + user_id integer NOT NULL, + packs_added integer NOT NULL DEFAULT 0, + loose_pills_added integer NOT NULL DEFAULT 0, + refill_date integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, ]; for (const sql of tableCreations) { @@ -150,6 +162,7 @@ async function createSchema(client: Client) { } async function clearData(client: Client) { + await client.execute("DELETE FROM refill_history"); await client.execute("DELETE FROM dose_tracking"); await client.execute("DELETE FROM share_tokens"); await client.execute("DELETE FROM user_settings"); @@ -230,6 +243,8 @@ describe("E2E Tests with Real Routes", () => { await app.register(medicationRoutes); await app.register(settingsRoutes); await app.register(healthRoutes); + await app.register(refillRoutes); + await app.register(exportRoutes); await app.ready(); }); @@ -1568,4 +1583,342 @@ describe("E2E Tests with Real Routes", () => { expect(response.statusCode).toBe(204); }); }); + + // --------------------------------------------------------------------------- + // Real Refill Routes Tests + // --------------------------------------------------------------------------- + + describe("Real /medications/:id/refill routes", () => { + it("should add refill to medication stock", async () => { + // Create medication first + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Refill Test Med", + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + // Add refill + const refillResponse = await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 1, loosePillsAdded: 10 }, + }); + + expect(refillResponse.statusCode).toBe(200); + const data = refillResponse.json(); + expect(data.success).toBe(true); + expect(data.newStock.packCount).toBe(3); // 2 + 1 + expect(data.newStock.looseTablets).toBe(15); // 5 + 10 + }); + + it("should return 400 when no packs or pills added", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Refill Test Med 2", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id; + + const response = await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 0, loosePillsAdded: 0 }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should return 404 for non-existent medication", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications/99999/refill", + payload: { packsAdded: 1 }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return 400 for invalid medication id", async () => { + const response = await app.inject({ + method: "POST", + url: "/medications/invalid/refill", + payload: { packsAdded: 1 }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("Real /medications/:id/refills routes (history)", () => { + it("should return empty array when no refills", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "No Refill Med", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id; + + const response = await app.inject({ + method: "GET", + url: `/medications/${medId}/refills`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([]); + }); + + it("should return refill history after adding refills", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "With Refills Med", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + const medId = createResponse.json().id; + + // Add two refills + await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 1, loosePillsAdded: 0 }, + }); + await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 0, loosePillsAdded: 5 }, + }); + + const response = await app.inject({ + method: "GET", + url: `/medications/${medId}/refills`, + }); + + expect(response.statusCode).toBe(200); + const refills = response.json(); + expect(refills).toHaveLength(2); + // Check both refills exist (order may vary) + const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0); + const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5); + expect(hasPackRefill).toBe(true); + expect(hasLooseRefill).toBe(true); + }); + + it("should return 404 for non-existent medication", async () => { + const response = await app.inject({ + method: "GET", + url: "/medications/99999/refills", + }); + + expect(response.statusCode).toBe(404); + }); + }); + + // --------------------------------------------------------------------------- + // Real Export/Import Routes Tests + // --------------------------------------------------------------------------- + + describe("Real /export routes", () => { + it("should export empty data when no medications", async () => { + const response = await app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.version).toBeDefined(); + expect(data.exportedAt).toBeDefined(); + expect(data.medications).toEqual([]); + }); + + it("should export medications with correct structure", async () => { + // Create a medication + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Export Test Med", + genericName: "Test Generic", + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + pillWeightMg: 500, + notes: "Test notes", + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + const response = await app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.medications).toHaveLength(1); + + const med = data.medications[0]; + expect(med.name).toBe("Export Test Med"); + expect(med.genericName).toBe("Test Generic"); + expect(med.inventory.packCount).toBe(2); + expect(med.inventory.blistersPerPack).toBe(3); + expect(med.inventory.pillsPerBlister).toBe(10); + expect(med.inventory.looseTablets).toBe(5); + expect(med.pillWeightMg).toBe(500); + expect(med.notes).toBe("Test notes"); + expect(med.schedules).toHaveLength(1); + }); + + it("should include settings when user has settings", async () => { + // Create settings first + await app.inject({ + method: "PUT", + url: "/settings", + payload: { + emailEnabled: true, + notificationEmail: "test@example.com", + }, + }); + + const response = await app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.settings).toBeDefined(); + expect(data.settings.emailEnabled).toBe(true); + }); + }); + + describe("Real /import routes", () => { + it("should import medications from export format", async () => { + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [ + { + _exportId: "med-1", + name: "Imported Med", + genericName: "Imported Generic", + takenBy: ["Person A"], + inventory: { + packCount: 3, + blistersPerPack: 2, + pillsPerBlister: 14, + looseTablets: 7, + }, + pillWeightMg: 250, + schedules: [ + { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z", remind: true } + ], + notes: "Imported notes", + intakeRemindersEnabled: true, + } + ], + }; + + const response = await app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + expect(response.statusCode).toBe(200); + const result = response.json(); + expect(result.success).toBe(true); + expect(result.imported.medications).toBe(1); + + // Verify medication was created + const medsResponse = await app.inject({ + method: "GET", + url: "/medications", + }); + const meds = medsResponse.json(); + expect(meds).toHaveLength(1); + expect(meds[0].name).toBe("Imported Med"); + }); + + it("should return 400 for invalid import data", async () => { + const response = await app.inject({ + method: "POST", + url: "/import", + payload: { invalid: "data" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should replace existing medications on import", async () => { + // First create a medication + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Existing Med", + packCount: 5, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + + // Verify it exists + let medsResponse = await app.inject({ method: "GET", url: "/medications" }); + expect(medsResponse.json()).toHaveLength(1); + expect(medsResponse.json()[0].name).toBe("Existing Med"); + expect(medsResponse.json()[0].packCount).toBe(5); + + // Import will REPLACE all data + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [ + { + _exportId: "med-1", + name: "Imported Med", + inventory: { packCount: 10, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 }, + schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + } + ], + }; + + const response = await app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + expect(response.statusCode).toBe(200); + const result = response.json(); + expect(result.success).toBe(true); + expect(result.imported.medications).toBe(1); + + // Verify: old med is gone, new med exists + medsResponse = await app.inject({ method: "GET", url: "/medications" }); + expect(medsResponse.json()).toHaveLength(1); + expect(medsResponse.json()[0].name).toBe("Imported Med"); + expect(medsResponse.json()[0].packCount).toBe(10); + }); + }); }); diff --git a/backend/src/test/refills.test.ts b/backend/src/test/refills.test.ts new file mode 100644 index 0000000..139d0f6 --- /dev/null +++ b/backend/src/test/refills.test.ts @@ -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); + }); + }); +}); diff --git a/backend/src/test/setup.ts b/backend/src/test/setup.ts index 80f81d4..159f5c3 100644 --- a/backend/src/test/setup.ts +++ b/backend/src/test/setup.ts @@ -9,8 +9,15 @@ import sensible from "@fastify/sensible"; import fastifyMultipart from "@fastify/multipart"; import { createClient, Client } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; import { beforeAll, afterAll, beforeEach } from "vitest"; -import { getTableCreationSQL } from "../db/schema-sql.js"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +// Get migrations folder path +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, "../../drizzle"); // Type for our test database export type TestDb = ReturnType; @@ -61,14 +68,11 @@ export async function buildTestApp(): Promise { } /** - * Create test database schema + * Create test database schema using drizzle-kit migrations */ async function runTestMigrations(client: Client): Promise { - const tableCreations = getTableCreationSQL(); - - for (const sql of tableCreations) { - await client.execute(sql); - } + const db = drizzle(client); + await migrate(db, { migrationsFolder }); } // ============================================================================= @@ -282,6 +286,7 @@ export async function closeTestApp(ctx: TestContext): Promise { */ export async function clearTestData(client: Client): Promise { // Order matters due to foreign keys + await client.execute("DELETE FROM refill_history"); await client.execute("DELETE FROM dose_tracking"); await client.execute("DELETE FROM share_tokens"); await client.execute("DELETE FROM refresh_tokens"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 27584ca..9b8c134 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,13 @@ type PlannerRow = { enough: boolean; }; +type RefillEntry = { + id: number; + packsAdded: number; + loosePillsAdded: number; + refillDate: string; +}; + type FormBlister = { usage: string; every: string; startDate: string; startTime: string }; type FormState = { @@ -337,6 +344,7 @@ function AppContent() { const [pendingImagePreview, setPendingImagePreview] = useState(null); const [selectedMed, setSelectedMed] = useState(null); const [showImageLightbox, setShowImageLightbox] = useState(false); + const [scheduleLightboxImage, setScheduleLightboxImage] = useState(null); const [selectedUser, setSelectedUser] = useState(null); const [scheduleDays, setScheduleDays] = useState(30); const [showPastDays, setShowPastDays] = useState(false); @@ -358,9 +366,18 @@ function AppContent() { // Export/Import state const [exporting, setExporting] = 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 [pendingImportData, setPendingImportData] = useState(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([]); + const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false); // Collapsed days state (manually collapsed days are persisted) const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set()); const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set()); @@ -515,7 +532,11 @@ function AppContent() { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { // Close modals in order of priority (topmost first) - if (showImageLightbox) { + if (userDropdownOpen) { + setUserDropdownOpen(false); + } else if (scheduleLightboxImage) { + setScheduleLightboxImage(null); + } else if (showImageLightbox) { setShowImageLightbox(false); } else if (showEditModal) { setShowEditModal(false); @@ -533,7 +554,54 @@ function AppContent() { }; document.addEventListener("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 useEffect(() => { @@ -556,6 +624,20 @@ function AppContent() { }; }, [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 const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled || settings.notificationEmail !== savedSettings.notificationEmail || @@ -739,6 +821,65 @@ function AppContent() { 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() { if (!settings.notificationEmail) return; setTestingEmail(true); @@ -1286,8 +1427,8 @@ function AppContent() { {theme === "dark" ? "☀️" : "🌙"} {authState?.authEnabled && user && ( -
-
- - - @@ -1398,7 +1539,7 @@ function AppContent() { med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft) ); return ( -
med && setSelectedMed(med)}> +
med && openMedDetail(med)}> {row.name} @@ -1467,7 +1608,7 @@ function AppContent() { med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft) ); return ( -
med && setSelectedMed(med)}> +
med && openMedDetail(med)}> @@ -1595,7 +1736,15 @@ function AppContent() { return (
-
{item.medName}{med?.intakeRemindersEnabled && 🔔}
+
+
med?.imageUrl && setScheduleLightboxImage(`/api/images/${med.imageUrl}`)} + > + +
+ {item.medName}{med?.intakeRemindersEnabled && 🔔} +
{item.total} {t('common.pills')} {t('common.total')}
@@ -1698,7 +1847,15 @@ function AppContent() { return (
-
{item.medName}{med?.intakeRemindersEnabled && 🔔}
+
+
med?.imageUrl && setScheduleLightboxImage(`/api/images/${med.imageUrl}`)} + > + +
+ {item.medName}{med?.intakeRemindersEnabled && 🔔} +
{item.total} {t('common.pills')} {t('common.total')} {status && @@ -1788,6 +1945,19 @@ function AppContent() {

{t('medications.list.title')}

+
{meds.map((med) => ( @@ -1807,7 +1977,7 @@ function AppContent() {
{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {t('common.pills')}
- +
@@ -1909,6 +2079,44 @@ function AppContent() { handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} /> + {/* Refill section - only shown when editing */} + {editingId && ( +
+

{t('refill.title')}

+
+ + + + {(refillPacks > 0 || refillLoose > 0) && ( + +{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')} + )} +
+
+ )} +