diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 79caf36..0740c5c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -159,9 +159,8 @@ Each blister defines a recurring intake: ### Key Medication Fields ```typescript { - name, genericName, takenBy, // Identity - packCount, stripsPerPack, tabsPerStrip, looseTablets, // Inventory - count, strips, stripSize, // Derived/legacy + name, genericName, takenByJson, // Identity (takenByJson is JSON array) + packCount, blistersPerPack, pillsPerBlister, looseTablets, // Inventory pillWeightMg, // For mg display usageJson, everyJson, startJson, // Intake schedules as JSON arrays imageUrl, expiryDate, notes, // Optional metadata @@ -207,141 +206,14 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp - **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars - **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json` -## ⚠️⚠️⚠️ Database Migrations (ABSOLUTELY CRITICAL) ⚠️⚠️⚠️ +## Database Schema Changes -**THIS IS NON-NEGOTIABLE: ALL database changes MUST work for EXISTING production databases!** +When adding new database columns: -Users update their Docker containers and expect the app to work with their existing data. If migrations don't run automatically, the app crashes with `SQLITE_ERROR: no such column` errors. - -### The Migration System - -The app uses **auto-migrations at startup** in `backend/src/db/client.ts`. This file: -1. Creates tables if they don't exist (fresh install) -2. Runs `ALTER TABLE ADD COLUMN` for each new column (existing databases) -3. Ignores "duplicate column" errors (migration already applied) - -### When adding/modifying database columns or tables, ALWAYS do ALL of the following: - -#### 1. Update schema: `backend/src/db/schema.ts` -```typescript -// Add the new column to the Drizzle schema -stockCalculationMode: text("stock_calculation_mode").notNull().default("automatic"), -``` - -#### 2. Update client.ts TABLE CREATION: `backend/src/db/client.ts` -Find the `CREATE TABLE IF NOT EXISTS` statement and add the new column: -```sql -CREATE TABLE IF NOT EXISTS user_settings ( - ... - stock_calculation_mode text NOT NULL DEFAULT 'automatic', -- ADD THIS LINE - ... -) -``` -**This is for FRESH installs** - new databases get all columns from the start. - -#### 3. Update client.ts MIGRATIONS ARRAY: `backend/src/db/client.ts` -Add an entry to the `migrations` array: -```typescript -const migrations = [ - ...existing migrations... - { name: "user_settings_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'" }, -]; -``` -**This is for EXISTING databases** - the ALTER TABLE adds the column to old databases. - -#### 4. Create migration SQL file (for documentation): `backend/src/db/migrations/XXXX_description.sql` -```sql --- Add stock calculation mode setting -ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'; -``` - -#### 5. Update journal: `backend/src/db/migrations/meta/_journal.json` -```json -{ "idx": X, "version": 1, "when": TIMESTAMP, "tag": "XXXX_description", "breakpoint": false } -``` - -#### 6. Update migrate.ts: `backend/src/db/migrate.ts` -Add the column to the `CREATE TABLE` statement AND to the `migrations` array. - -### ⚠️ CRITICAL CHECKLIST - DO NOT SKIP ANY STEP: - -| Step | File | Purpose | If Missing | -|------|------|---------|------------| -| 1 | `schema.ts` | Drizzle ORM knows about column | TypeScript errors | -| 2 | `client.ts` (CREATE TABLE) | Fresh installs have column | Fresh installs crash | -| 3 | `client.ts` (migrations array) | Existing DBs get column | **PRODUCTION CRASHES** | -| 4 | `migrations/*.sql` | Documentation | None (but keep for history) | -| 5 | `_journal.json` | Migration tracking | None (but keep for history) | -| 6 | `migrate.ts` | CLI migration tool | CLI tool fails | - -**Step 3 is the most critical!** Without it, users who update their Docker container will get `SQLITE_ERROR: no such column` and the app will not start. - -### Testing Migrations - -Before pushing changes: -1. Test with fresh database: Delete `backend/data/medassist-ng.db` and restart -2. Test with existing database: Keep old DB and restart - new columns should be added automatically - -## ⚠️ Defensive Coding (CRITICAL for Production) - -**ALL new optional fields MUST be handled defensively in both Backend AND Frontend!** - -When a user updates their app, old data in the database may not have new fields. The frontend receives this data and crashes with `TypeError: Cannot read property 'length' of undefined`. - -### Rules for New Optional/Array Fields: - -#### Backend (routes/*.ts): -Always provide default values when returning data: -```typescript -// ✅ CORRECT - Always return array, even if DB value is null/undefined -takenBy: parseTakenByJson(row.takenByJson), // Returns [] if null/undefined - -// Parser function example: -function parseTakenByJson(value: string | null | undefined): string[] { - if (!value) return []; - try { return JSON.parse(value) || []; } - catch { return []; } -} -``` - -#### Frontend (App.tsx): -Always use defensive checks when accessing optional properties: -```typescript -// ✅ CORRECT - Defensive checks -med?.takenBy && med.takenBy.length > 0 -(m.takenBy || []).includes(selectedUser) -(d.takenBy || []).length > 0 ? d.takenBy : [null] -const personCount = Math.max(1, m.takenBy?.length || 1); - -// ❌ WRONG - Will crash if takenBy is undefined -m.takenBy.includes(selectedUser) // TypeError! -m.takenBy.length > 0 // TypeError! -``` - -### Checklist for New Optional Fields: - -| Location | Action | -|----------|--------| -| Backend route | Return default value (`[]`, `null`, `0`, etc.) | -| Frontend type | Mark as optional: `takenBy?: string[]` | -| Frontend access | ALWAYS use `?.`, `|| []`, or null-check before `.length`, `.map()`, `.includes()` | -| Schedule builders | Pass default: `takenBy: med.takenBy || []` | - -### Common Patterns: -```typescript -// Arrays - always default to [] -const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; -meds.flatMap(m => m.takenBy || []); - -// Optional chaining for nested access -med?.takenBy?.length > 0 - -// Filter with optional check -meds.filter(m => (m.takenBy || []).includes(name)) - -// Conditional rendering -{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map(...)} -``` +1. **Update schema**: `backend/src/db/schema.ts` - Add the Drizzle column definition +2. **Update client.ts**: `backend/src/db/client.ts` - Add column to `CREATE TABLE IF NOT EXISTS` +3. **Update migrate.ts**: `backend/src/db/migrate.ts` - Same as client.ts +4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart ## File Locations @@ -349,8 +221,6 @@ meds.filter(m => (m.takenBy || []).includes(name)) |---------|----------| | Backend entry | `backend/src/index.ts` | | Database schema | `backend/src/db/schema.ts` | -| Migrations | `backend/src/db/migrations/*.sql` | -| Migration journal | `backend/src/db/migrations/meta/_journal.json` | | Backend routes | `backend/src/routes/*.ts` | | Backend services | `backend/src/services/*.ts` | | Frontend app | `frontend/src/App.tsx` | diff --git a/backend/package-lock.json b/backend/package-lock.json index 5bc2188..bd3e76c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1543,7 +1543,6 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz", "integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==", "license": "MIT", - "peer": true, "dependencies": { "@libsql/core": "^0.10.0", "@libsql/hrana-client": "^0.6.2", diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index f341cea..7548d5c 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -65,8 +65,9 @@ async function runMigrations() { id integer PRIMARY KEY AUTOINCREMENT, username text NOT NULL UNIQUE, password_hash text, - email 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')), @@ -77,19 +78,15 @@ async function runMigrations() { user_id integer NOT NULL, name text NOT NULL, generic_name text, - taken_by text, taken_by_json text NOT NULL DEFAULT '[]', - count integer NOT NULL DEFAULT 0, - strips integer NOT NULL DEFAULT 0, pack_count integer NOT NULL DEFAULT 1, - strips_per_pack integer NOT NULL DEFAULT 1, - tabs_per_strip 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 '[]', - strip_size integer NOT NULL DEFAULT 1, image_url text, expiry_date text, notes text, @@ -161,36 +158,6 @@ async function runMigrations() { } console.log(`[DB] Tables verified/created`); - // Then run column migrations for existing databases - const migrations = [ - { name: "image_url", sql: "ALTER TABLE medications ADD COLUMN image_url TEXT" }, - { name: "expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date TEXT" }, - { name: "notes", sql: "ALTER TABLE medications ADD COLUMN notes TEXT" }, - { name: "generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name TEXT" }, - { name: "intake_reminders_enabled", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0" }, - { name: "pill_weight_mg", sql: "ALTER TABLE medications ADD COLUMN pill_weight_mg REAL" }, - { name: "taken_by", sql: "ALTER TABLE medications ADD COLUMN taken_by TEXT" }, - { name: "taken_by_json", sql: "ALTER TABLE medications ADD COLUMN taken_by_json TEXT NOT NULL DEFAULT '[]'" }, - { name: "users_email", sql: "ALTER TABLE users ADD COLUMN email TEXT" }, - { name: "users_avatar_url", sql: "ALTER TABLE users ADD COLUMN avatar_url TEXT" }, - { name: "users_oidc_subject", sql: "ALTER TABLE users ADD COLUMN oidc_subject TEXT" }, - { name: "user_settings_expiry_warning_days", sql: "ALTER TABLE user_settings ADD COLUMN expiry_warning_days INTEGER NOT NULL DEFAULT 90" }, - { name: "user_settings_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'" }, - { name: "share_tokens_expires_at", sql: "ALTER TABLE share_tokens ADD COLUMN expires_at INTEGER" }, - ]; - - for (const migration of migrations) { - try { - await client.execute(migration.sql); - console.log(`[DB] Migration applied: ${migration.name}`); - } catch (e: any) { - // Ignore "duplicate column" errors - column already exists - if (!e.message?.includes("duplicate column")) { - console.error(`[DB] Migration error (${migration.name}):`, e.message); - } - } - } - // If auth is disabled, ensure a default user exists (ID=1) const authEnabled = process.env.AUTH_ENABLED === "true"; if (!authEnabled) { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 2d9e0d0..52243c3 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -33,19 +33,15 @@ async function main() { user_id integer NOT NULL, name text NOT NULL, generic_name text, - taken_by text, taken_by_json text NOT NULL DEFAULT '[]', - count integer NOT NULL DEFAULT 0, - strips integer NOT NULL DEFAULT 0, pack_count integer NOT NULL DEFAULT 1, - strips_per_pack integer NOT NULL DEFAULT 1, - tabs_per_strip 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 '[]', - strip_size integer NOT NULL DEFAULT 1, image_url text, expiry_date text, notes text, @@ -119,52 +115,6 @@ async function main() { await client.execute(stmt); } - console.log("Base tables created. Running migrations for existing databases..."); - - // Run incremental migrations for existing databases - // These ALTER TABLE statements are safe to run multiple times (they'll fail silently if column exists) - const migrations = [ - // 0003: Add image_url to medications - { name: "0003_add_image_url", sql: "ALTER TABLE medications ADD COLUMN image_url text" }, - // 0004: Add expiry_date to medications - { name: "0004_add_expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date text" }, - // 0005: Add notes to medications - { name: "0005_add_notes", sql: "ALTER TABLE medications ADD COLUMN notes text" }, - // 0006: Add generic_name to medications - { name: "0006_add_generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name text" }, - // 0007: Add intake_reminders_enabled to medications - { name: "0007_add_intake_reminders", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled integer NOT NULL DEFAULT 0" }, - // 0008: Add pill_weight_mg to medications - { name: "0008_add_pill_weight", sql: "ALTER TABLE medications ADD COLUMN pill_weight_mg integer" }, - // 0009: Add taken_by to medications - { name: "0009_add_taken_by", sql: "ALTER TABLE medications ADD COLUMN taken_by text" }, - // 0012: Add avatar_url to users - { name: "0012_add_user_avatar", sql: "ALTER TABLE users ADD COLUMN avatar_url text" }, - // 0013: Add oidc_subject to users - { name: "0013_add_oidc_subject", sql: "ALTER TABLE users ADD COLUMN oidc_subject text" }, - // 0014: Add stock_calculation_mode to user_settings - { name: "0014_add_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'" }, - // 0015: Add expires_at to share_tokens - { name: "0015_add_share_token_expiry", sql: "ALTER TABLE share_tokens ADD COLUMN expires_at integer" }, - // 0016: Add taken_by_json to medications - { name: "0016_taken_by_json_array", sql: "ALTER TABLE medications ADD COLUMN taken_by_json text NOT NULL DEFAULT '[]'" }, - ]; - - for (const migration of migrations) { - try { - await client.execute(migration.sql); - console.log(`Migration ${migration.name}: applied`); - } catch (err: unknown) { - // Ignore "duplicate column" errors - means migration was already applied - const errorMessage = err instanceof Error ? err.message : String(err); - if (errorMessage.includes("duplicate column") || errorMessage.includes("already exists")) { - console.log(`Migration ${migration.name}: already applied (skipped)`); - } else { - console.error(`Migration ${migration.name}: failed - ${errorMessage}`); - } - } - } - console.log("Database setup complete!"); process.exit(0); } diff --git a/backend/src/db/migrations/0003_add_image_url.sql b/backend/src/db/migrations/0003_add_image_url.sql deleted file mode 100644 index 21afee6..0000000 --- a/backend/src/db/migrations/0003_add_image_url.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Migration 0003: Add image_url column for medication photos -ALTER TABLE medications ADD COLUMN image_url TEXT; diff --git a/backend/src/db/migrations/0004_add_expiry_date.sql b/backend/src/db/migrations/0004_add_expiry_date.sql deleted file mode 100644 index 434dac6..0000000 --- a/backend/src/db/migrations/0004_add_expiry_date.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Migration 0004: Add expiry_date column for medication expiration tracking -ALTER TABLE medications ADD COLUMN expiry_date TEXT; diff --git a/backend/src/db/migrations/0005_add_notes.sql b/backend/src/db/migrations/0005_add_notes.sql deleted file mode 100644 index 1c4fd96..0000000 --- a/backend/src/db/migrations/0005_add_notes.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add notes column for medication instructions -ALTER TABLE medications ADD COLUMN notes TEXT; diff --git a/backend/src/db/migrations/0006_add_generic_name.sql b/backend/src/db/migrations/0006_add_generic_name.sql deleted file mode 100644 index 0fb906d..0000000 --- a/backend/src/db/migrations/0006_add_generic_name.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add generic_name column for medication active ingredient -ALTER TABLE medications ADD COLUMN generic_name TEXT; diff --git a/backend/src/db/migrations/0007_add_intake_reminders.sql b/backend/src/db/migrations/0007_add_intake_reminders.sql deleted file mode 100644 index a5ae1e7..0000000 --- a/backend/src/db/migrations/0007_add_intake_reminders.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add intake_reminders_enabled column to medications table -ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0; diff --git a/backend/src/db/migrations/0008_add_pill_weight.sql b/backend/src/db/migrations/0008_add_pill_weight.sql deleted file mode 100644 index 02c45e8..0000000 --- a/backend/src/db/migrations/0008_add_pill_weight.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add pill weight column (in mg) -ALTER TABLE medications ADD COLUMN pill_weight_mg INTEGER; diff --git a/backend/src/db/migrations/0009_add_taken_by.sql b/backend/src/db/migrations/0009_add_taken_by.sql deleted file mode 100644 index 9f8877a..0000000 --- a/backend/src/db/migrations/0009_add_taken_by.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add taken_by column for family member tracking -ALTER TABLE medications ADD COLUMN taken_by TEXT; diff --git a/backend/src/db/migrations/0010_add_user_settings.sql b/backend/src/db/migrations/0010_add_user_settings.sql deleted file mode 100644 index e400587..0000000 --- a/backend/src/db/migrations/0010_add_user_settings.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Add user_id to medications (for existing databases) --- First, add the column as nullable -ALTER TABLE medications ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; - --- Create user_settings table for per-user notification settings -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, - language text NOT NULL DEFAULT 'en', - 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 -); diff --git a/backend/src/db/migrations/0011_add_dose_tracking.sql b/backend/src/db/migrations/0011_add_dose_tracking.sql deleted file mode 100644 index ce49d4a..0000000 --- a/backend/src/db/migrations/0011_add_dose_tracking.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Dose tracking table for syncing taken doses between users and share links -CREATE TABLE IF NOT EXISTS dose_tracking ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - dose_id TEXT NOT NULL, - taken_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), - marked_by TEXT -); - --- Index for fast lookups by user and dose -CREATE INDEX IF NOT EXISTS idx_dose_tracking_user_dose ON dose_tracking(user_id, dose_id); diff --git a/backend/src/db/migrations/0012_add_user_avatar.sql b/backend/src/db/migrations/0012_add_user_avatar.sql deleted file mode 100644 index e94b81a..0000000 --- a/backend/src/db/migrations/0012_add_user_avatar.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add avatar URL column to users table -ALTER TABLE users ADD COLUMN avatar_url TEXT; diff --git a/backend/src/db/migrations/0013_add_oidc_subject.sql b/backend/src/db/migrations/0013_add_oidc_subject.sql deleted file mode 100644 index 395e0eb..0000000 --- a/backend/src/db/migrations/0013_add_oidc_subject.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add OIDC subject column for SSO user identification -ALTER TABLE users ADD COLUMN oidc_subject TEXT; diff --git a/backend/src/db/migrations/0014_add_stock_calculation_mode.sql b/backend/src/db/migrations/0014_add_stock_calculation_mode.sql deleted file mode 100644 index 3f3e97d..0000000 --- a/backend/src/db/migrations/0014_add_stock_calculation_mode.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add stock calculation mode setting --- "automatic" = stock decreases based on schedule from start date --- "manual" = stock only decreases when doses are marked as taken -ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'; diff --git a/backend/src/db/migrations/0015_add_share_token_expiry.sql b/backend/src/db/migrations/0015_add_share_token_expiry.sql deleted file mode 100644 index 37a16dd..0000000 --- a/backend/src/db/migrations/0015_add_share_token_expiry.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add expiration date to share tokens (default 1 year from creation) --- NULL means no expiration (for backwards compatibility with existing tokens) -ALTER TABLE share_tokens ADD COLUMN expires_at INTEGER; diff --git a/backend/src/db/migrations/0016_taken_by_json_array.sql b/backend/src/db/migrations/0016_taken_by_json_array.sql deleted file mode 100644 index 05a7c1e..0000000 --- a/backend/src/db/migrations/0016_taken_by_json_array.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Convert taken_by from single string to JSON array --- This allows multiple people to share the same medication - --- Add new column for JSON array -ALTER TABLE medications ADD COLUMN taken_by_json TEXT NOT NULL DEFAULT '[]'; - --- Migrate existing data: convert single string to JSON array --- If taken_by is not null/empty, convert to ["value"], otherwise keep as [] -UPDATE medications -SET taken_by_json = CASE - WHEN taken_by IS NOT NULL AND taken_by != '' - THEN json_array(taken_by) - ELSE '[]' -END; - --- Note: We keep the old taken_by column for backwards compatibility during migration --- It can be dropped in a future migration once all code uses taken_by_json diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json deleted file mode 100644 index d926526..0000000 --- a/backend/src/db/migrations/meta/_journal.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "entries": [ - { "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false }, - { "idx": 1, "version": 1, "when": 1734700000, "tag": "0001_add_strips", "breakpoint": false }, - { "idx": 2, "version": 1, "when": 1734800000, "tag": "0002_pack_inventory", "breakpoint": false }, - { "idx": 3, "version": 1, "when": 1734900000, "tag": "0003_add_image_url", "breakpoint": false }, - { "idx": 4, "version": 1, "when": 1735000000, "tag": "0004_add_expiry_date", "breakpoint": false }, - { "idx": 5, "version": 1, "when": 1735100000, "tag": "0005_add_notes", "breakpoint": false }, - { "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false }, - { "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false }, - { "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false }, - { "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false }, - { "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false }, - { "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false }, - { "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false }, - { "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false }, - { "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false }, - { "idx": 15, "version": 1, "when": 1735400001, "tag": "0015_add_share_token_expiry", "breakpoint": false }, - { "idx": 16, "version": 1, "when": 1735400002, "tag": "0016_taken_by_json_array", "breakpoint": false } - ] -} diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 8431886..463c3e1 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -25,19 +25,15 @@ export const medications = sqliteTable("medications", { userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), name: text("name", { length: 100 }).notNull(), genericName: text("generic_name", { length: 100 }), - takenBy: text("taken_by", { length: 100 }), // Deprecated: use takenByJson takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names - count: integer("count").notNull().default(0), - strips: integer("strips").notNull().default(0), packCount: integer("pack_count").notNull().default(1), - stripsPerPack: integer("strips_per_pack").notNull().default(1), - tabsPerStrip: integer("tabs_per_strip").notNull().default(1), + blistersPerPack: integer("blisters_per_pack").notNull().default(1), + pillsPerBlister: integer("pills_per_blister").notNull().default(1), looseTablets: integer("loose_tablets").notNull().default(0), pillWeightMg: integer("pill_weight_mg"), usageJson: text("usage_json").notNull().default("[]"), everyJson: text("every_json").notNull().default("[]"), startJson: text("start_json").notNull().default("[]"), - stripSize: integer("strip_size").notNull().default(1), imageUrl: text("image_url"), expiryDate: text("expiry_date"), notes: text("notes"), diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 8a5441c..bf18751 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -23,8 +23,8 @@ const medicationSchema = z.object({ genericName: z.string().trim().max(100).nullable().optional(), takenBy: z.array(z.string().trim().max(100)).default([]), // Array of person names packCount: z.number().int().min(0).default(1), - stripsPerPack: z.number().int().min(1).default(1), - tabsPerStrip: z.number().int().min(1).default(1), + blistersPerPack: z.number().int().min(1).default(1), + pillsPerBlister: z.number().int().min(1).default(1), looseTablets: z.number().int().min(0).default(0), pillWeightMg: z.number().int().min(1).nullable().optional(), expiryDate: z.string().nullable().optional(), @@ -92,12 +92,9 @@ export async function medicationRoutes(app: FastifyInstance) { name: row.name, genericName: row.genericName, takenBy: parseTakenByJson(row.takenByJson), - count: row.count, - strips: row.strips, - stripSize: row.stripSize, packCount: row.packCount ?? 1, - stripsPerPack: row.stripsPerPack ?? row.strips ?? 1, - tabsPerStrip: row.tabsPerStrip ?? row.stripSize ?? 1, + blistersPerPack: row.blistersPerPack ?? 1, + pillsPerBlister: row.pillsPerBlister ?? 1, looseTablets: row.looseTablets ?? 0, pillWeightMg: row.pillWeightMg, blisters: parseBlisters(row), @@ -114,28 +111,22 @@ export async function medicationRoutes(app: FastifyInstance) { if (!parsed.success) return reply.status(400).send(parsed.error.format()); const userId = await getUserId(req, reply); - const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data; + const { name, genericName, takenBy, packCount, blistersPerPack, pillsPerBlister, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data; const usageJson = JSON.stringify(blisters.map((s) => s.usage)); const everyJson = JSON.stringify(blisters.map((s) => s.every)); const startJson = JSON.stringify(blisters.map((s) => s.start)); const takenByJson = JSON.stringify(takenBy || []); - const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets); - const [inserted] = await db .insert(medications) .values({ userId, name, genericName: genericName || null, - takenBy: (takenBy && takenBy.length > 0) ? takenBy[0] : null, // Backwards compat takenByJson, - count: derivedCount, - strips: stripsPerPack, - stripSize: tabsPerStrip, packCount, - stripsPerPack, - tabsPerStrip, + blistersPerPack, + pillsPerBlister, looseTablets, pillWeightMg: pillWeightMg || null, expiryDate: expiryDate || null, @@ -152,12 +143,9 @@ export async function medicationRoutes(app: FastifyInstance) { name: inserted.name, genericName: inserted.genericName, takenBy: parseTakenByJson(inserted.takenByJson), - count: inserted.count, - strips: inserted.strips, - stripSize: inserted.stripSize, packCount: inserted.packCount, - stripsPerPack: inserted.stripsPerPack, - tabsPerStrip: inserted.tabsPerStrip, + blistersPerPack: inserted.blistersPerPack, + pillsPerBlister: inserted.pillsPerBlister, looseTablets: inserted.looseTablets, pillWeightMg: inserted.pillWeightMg, blisters, @@ -181,27 +169,21 @@ export async function medicationRoutes(app: FastifyInstance) { const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); - const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data; + const { name, genericName, takenBy, packCount, blistersPerPack, pillsPerBlister, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data; const usageJson = JSON.stringify(blisters.map((s) => s.usage)); const everyJson = JSON.stringify(blisters.map((s) => s.every)); const startJson = JSON.stringify(blisters.map((s) => s.start)); const takenByJson = JSON.stringify(takenBy || []); - const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets); - const result = await db .update(medications) .set({ name, genericName: genericName || null, - takenBy: (takenBy && takenBy.length > 0) ? takenBy[0] : null, // Backwards compat takenByJson, - count: derivedCount, - strips: stripsPerPack, - stripSize: tabsPerStrip, packCount, - stripsPerPack, - tabsPerStrip, + blistersPerPack, + pillsPerBlister, looseTablets, pillWeightMg: pillWeightMg || null, expiryDate: expiryDate || null, @@ -222,12 +204,9 @@ export async function medicationRoutes(app: FastifyInstance) { name: result[0].name, genericName: result[0].genericName, takenBy: parseTakenByJson(result[0].takenByJson), - count: result[0].count, - strips: result[0].strips, - stripSize: result[0].stripSize, packCount: result[0].packCount, - stripsPerPack: result[0].stripsPerPack, - tabsPerStrip: result[0].tabsPerStrip, + blistersPerPack: result[0].blistersPerPack, + pillsPerBlister: result[0].pillsPerBlister, looseTablets: result[0].looseTablets, pillWeightMg: result[0].pillWeightMg, blisters, @@ -329,11 +308,11 @@ export async function medicationRoutes(app: FastifyInstance) { const payload = rows.map((row) => { const blisters = parseBlisters(row); const usageTotal = calculateUsageInRange(blisters, start, end); - const tabsPerStrip = row.tabsPerStrip ?? row.stripSize ?? 1; + const pillsPerBlister = row.pillsPerBlister ?? 1; const packCount = row.packCount ?? 1; - const stripsPerPack = row.stripsPerPack ?? row.strips ?? 1; + const blistersPerPack = row.blistersPerPack ?? 1; const looseTablets = row.looseTablets ?? 0; - const originalTotalPills = packCount * stripsPerPack * tabsPerStrip + looseTablets; + const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets; // Calculate consumption up to now (same logic as frontend) let consumedUntilNow = 0; @@ -347,7 +326,7 @@ export async function medicationRoutes(app: FastifyInstance) { }); const currentPills = Math.max(0, originalTotalPills - consumedUntilNow); - const stripsNeeded = tabsPerStrip > 0 ? Math.ceil(usageTotal / tabsPerStrip) : 0; + const stripsNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0; // Calculate current stock using realistic consumption order (loose first, then blisters) const consumed = originalTotalPills - currentPills; @@ -357,8 +336,8 @@ export async function medicationRoutes(app: FastifyInstance) { const originalBlisterPills = originalTotalPills - looseTablets; const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed); - const fullBlisters = tabsPerStrip > 0 ? Math.floor(blisterPillsRemaining / tabsPerStrip) : 0; - const openBlisterPills = tabsPerStrip > 0 ? blisterPillsRemaining % tabsPerStrip : 0; + const fullBlisters = pillsPerBlister > 0 ? Math.floor(blisterPillsRemaining / pillsPerBlister) : 0; + const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0; const loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose const enough = currentPills >= usageTotal; @@ -367,7 +346,7 @@ export async function medicationRoutes(app: FastifyInstance) { medicationName: row.name, totalPills: currentPills, plannerUsage: usageTotal, - stripSize: tabsPerStrip, + stripSize: pillsPerBlister, stripsNeeded, fullBlisters, loosePills, @@ -390,13 +369,4 @@ function calculateUsageInRange(blisters: Array<{ usage: number; every: number; s } }); return Number(total.toFixed(2)); -} - -function deriveTotalTablets(packCount: number, stripsPerPack: number, tabsPerStrip: number, looseTablets: number) { - const packs = packCount || 0; - const strips = stripsPerPack || 0; - const tabs = tabsPerStrip || 1; - const loose = looseTablets || 0; - const packed = packs * strips * tabs; - return packed + loose; } \ No newline at end of file diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index d0d819d..281d636 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -254,12 +254,6 @@ async function findOrCreateOIDCUser( // User already has a DIFFERENT OIDC subject - create new user with suffix username = `${username}_sso`; console.log(`[OIDC] Username collision (different OIDC subject), using: ${username}`); - } else if (existingByUsername.authProvider === "oidc" && !existingByUsername.oidcSubject) { - // Legacy OIDC user without subject - update it - await db.update(users) - .set({ oidcSubject: oidcSubject }) - .where(eq(users.id, existingByUsername.id)); - return { id: existingByUsername.id, username: existingByUsername.username }; } } diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 705c5ce..3a114a9 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -107,14 +107,15 @@ export async function shareRoutes(app: FastifyInstance) { blisters = []; } + const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets; return { id: med.id, name: med.name, genericName: med.genericName, pillWeightMg: med.pillWeightMg, imageUrl: med.imageUrl, - count: med.count, - tabsPerStrip: med.tabsPerStrip, + totalPills, + pillsPerBlister: med.pillsPerBlister, blisters, }; }); diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index f5fb277..312da5a 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -237,13 +237,14 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: for (const row of rows) { const blisters = parseBlisters(row); - const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, blisters }, language); + const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets; + const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language); // Check if medication runs out within reminderDaysBefore days if (daysLeft !== null && daysLeft <= reminderDaysBefore) { lowStock.push({ name: row.name, - medsLeft: row.count, + medsLeft: totalPills, daysLeft, depletionDate, }); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4dbc967..29f39f7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,7 +2,7 @@ services: backend-dev: image: node:22-slim working_dir: /app - command: sh -c "npm install && npm run dev" + command: sh -c "chown -R node:node /app/node_modules /app/data && su node -c 'npm install && npm run dev'" volumes: - ./backend:/app - backend_node_modules:/app/node_modules @@ -23,7 +23,7 @@ services: frontend-dev: image: node:22-slim working_dir: /app - command: sh -c "npm install && npm run dev -- --host --port 5173" + command: sh -c "chown -R node:node /app/node_modules && su node -c 'npm install && npm run dev -- --host --port 5173'" volumes: - ./frontend:/app - frontend_node_modules:/app/node_modules diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 91fbfc7..5b88e38 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,14 +13,11 @@ type Medication = { id: number; name: string; genericName?: string | null; - takenBy: string[]; // Changed from string | null to array - count: number; - strips: number; - stripSize: number; - packCount?: number; - stripsPerPack?: number; - tabsPerStrip?: number; - looseTablets?: number; + takenBy: string[]; + packCount: number; + blistersPerPack: number; + pillsPerBlister: number; + looseTablets: number; pillWeightMg?: number | null; blisters: Blister[]; imageUrl?: string | null; @@ -49,8 +46,8 @@ type FormState = { genericName: string; takenBy: string[]; // Changed from string to array packCount: string; - stripsPerPack: string; - tabsPerStrip: string; + blistersPerPack: string; + pillsPerBlister: string; looseTablets: string; pillWeightMg: string; expiryDate: string; @@ -69,7 +66,7 @@ const defaultBlister = (): FormBlister => { }; }; -const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: [], packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] }); +const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: [], packCount: "1", blistersPerPack: "1", pillsPerBlister: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] }); // Field validation limits (must match backend) const FIELD_LIMITS = { @@ -389,6 +386,25 @@ function AppContent() { } }, [user?.id]); + // Get dose ID with optional person suffix + function getDoseId(baseDoseId: string, person: string | null): string { + return person ? `${baseDoseId}-${person}` : baseDoseId; + } + + // Count taken doses for a day/item + function countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } { + let total = 0; + let taken = 0; + for (const d of doses) { + const people = (d.takenBy || []).length > 0 ? d.takenBy : [null]; + for (const person of people) { + total++; + if (takenDoses.has(getDoseId(d.id, person))) taken++; + } + } + return { total, taken }; + } + async function markDoseTaken(doseId: string) { // Optimistic update setTakenDoses((prev) => { @@ -802,10 +818,10 @@ function AppContent() { name: med.name, genericName: med.genericName ?? "", takenBy: med.takenBy || [], // Already an array from API - packCount: String(med.packCount ?? 1), - stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1), - tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1), - looseTablets: String(med.looseTablets ?? 0), + packCount: String(med.packCount), + blistersPerPack: String(med.blistersPerPack), + pillsPerBlister: String(med.pillsPerBlister), + looseTablets: String(med.looseTablets), pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "", expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "", notes: med.notes ?? "", @@ -874,8 +890,8 @@ function AppContent() { genericName: form.genericName.trim() || null, takenBy: form.takenBy.filter(name => name.trim()), // Send array, filter empty strings packCount: Number(form.packCount) || 0, - stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1), - tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1), + blistersPerPack: Math.max(1, Number(form.blistersPerPack) || 1), + pillsPerBlister: Math.max(1, Number(form.pillsPerBlister) || 1), looseTablets: Math.max(0, Number(form.looseTablets) || 0), pillWeightMg: form.pillWeightMg ? Number(form.pillWeightMg) : null, expiryDate: form.expiryDate || null, @@ -1196,9 +1212,9 @@ function AppContent() { const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text"; const stock = getBlisterStock( Math.round(row.medsLeft), - med?.tabsPerStrip ?? 1, + med?.pillsPerBlister ?? 1, med?.looseTablets ?? 0, - med?.count ?? Math.round(row.medsLeft) + med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft) ); return (
med && setSelectedMed(med)}> @@ -1216,7 +1232,7 @@ function AppContent() { )} {formatFullBlisters(stock.fullBlisters, t)} - {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)} + {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)} {formatNumber(row.daysLeft)} {t(status.label)} {row.depletionDate ?? "-"} @@ -1265,9 +1281,9 @@ function AppContent() { const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text"; const stock = getBlisterStock( Math.round(row.medsLeft), - med?.tabsPerStrip ?? 1, + med?.pillsPerBlister ?? 1, med?.looseTablets ?? 0, - med?.count ?? Math.round(row.medsLeft) + med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft) ); return (
med && setSelectedMed(med)}> @@ -1287,7 +1303,7 @@ function AppContent() { )} {formatFullBlisters(stock.fullBlisters, t)} - {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)} + {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)} {formatNumber(row.daysLeft)} {row.depletionDate ?? "-"} {med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"} @@ -1398,16 +1414,15 @@ function AppContent() { {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
{people.map((person) => { - const personDoseId = person ? `${dose.id}-${person}` : dose.id; - // Check both new format (with person) and legacy format (without person suffix) - const isTaken = takenDoses.has(personDoseId) || (person && takenDoses.has(dose.id)); + const doseId = getDoseId(dose.id, person); + const isTaken = takenDoses.has(doseId); return ( -
+
{person && setSelectedUser(person)}>{person}} {isTaken ? ( - + ) : ( - + )}
); @@ -1512,16 +1527,15 @@ function AppContent() { {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
{people.map((person) => { - const personDoseId = person ? `${dose.id}-${person}` : dose.id; - // Check both new format (with person) and legacy format (without person suffix) - const isTaken = takenDoses.has(personDoseId) || (person && takenDoses.has(dose.id)); + const doseId = getDoseId(dose.id, person); + const isTaken = takenDoses.has(doseId); return ( -
+
{person && setSelectedUser(person)}>{person}} {isTaken ? ( - + ) : ( - + )}
); @@ -1559,12 +1573,12 @@ function AppContent() {
{med.name}
- {t('medications.details.packs')}: {med.packCount ?? 1} - {t('medications.details.blisters')}: {med.stripsPerPack ?? med.strips ?? 1} - {t('medications.details.pillsPerBlister')}: {med.tabsPerStrip ?? med.stripSize} - {t('medications.details.loose')}: {med.looseTablets ?? 0} + {t('medications.details.packs')}: {med.packCount} + {t('medications.details.blisters')}: {med.blistersPerPack} + {t('medications.details.pillsPerBlister')}: {med.pillsPerBlister} + {t('medications.details.loose')}: {med.looseTablets}
-
{t('medications.details.total')}: {med.count} {t('common.pills')}
+
{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {t('common.pills')}
@@ -1646,11 +1660,11 @@ function AppContent() {