571d94bf7e
## Package Type Feature - Add 'blister' and 'bottle' package types for medications - Bottle type uses totalPills for capacity and looseTablets for current stock - Blister type continues to use packCount/blistersPerPack/pillsPerBlister - Add doseUnit field for flexible dosing (mg, ml, IU, etc.) - Full UI support in medication form and detail modal ## Per-Intake TakenBy - Move takenBy from medication level to individual intakes - Each intake schedule can now be assigned to a different person - Update scheduler-utils to handle per-intake takenBy - Update SharedSchedule to filter by per-intake takenBy - Backward compatible with existing medication data ## UI Improvements - Add PasswordInput component with show/hide toggle - Centralize stockThresholds in AppContext for consistent status display - Fix SharedSchedule sync issues with per-intake takenBy - Improve mobile editing experience ## Technical - Add migrations 0004 and 0005 for schema changes - Update all relevant tests (1064 tests passing) - Maintain backward compatibility with ALTER migrations
158 lines
9.3 KiB
TypeScript
158 lines
9.3 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
|
|
// =============================================================================
|
|
// Users - Simple auth, no roles (every user is equal)
|
|
// =============================================================================
|
|
export const users = sqliteTable("users", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
username: text("username", { length: 100 }).notNull().unique(),
|
|
passwordHash: text("password_hash", { length: 255 }),
|
|
avatarUrl: text("avatar_url", { length: 255 }),
|
|
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
|
|
oidcSubject: text("oidc_subject", { length: 255 }), // OIDC provider's unique user ID (sub claim)
|
|
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
|
lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
|
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
});
|
|
|
|
// =============================================================================
|
|
// Medications - Per user
|
|
// =============================================================================
|
|
export const medications = sqliteTable("medications", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
name: text("name", { length: 100 }).notNull(),
|
|
genericName: text("generic_name", { length: 100 }),
|
|
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
|
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
|
|
packCount: integer("pack_count").notNull().default(1),
|
|
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
|
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
|
totalPills: integer("total_pills"), // For bottle type: total capacity of the container
|
|
looseTablets: integer("loose_tablets").notNull().default(0), // For blister: extra loose pills; for bottle: current stock
|
|
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
|
|
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
|
|
pillWeightMg: integer("pill_weight_mg"),
|
|
doseUnit: text("dose_unit", { length: 20 }).default("mg"), // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
|
usageJson: text("usage_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
|
|
everyJson: text("every_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
|
|
startJson: text("start_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
|
|
// New unified intakes structure: [{usage, every, start, takenBy, intakeRemindersEnabled}]
|
|
intakesJson: text("intakes_json").notNull().default("[]"),
|
|
imageUrl: text("image_url"),
|
|
expiryDate: text("expiry_date"),
|
|
notes: text("notes"),
|
|
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
|
dismissedUntil: text("dismissed_until"), // ISO date string (e.g. "2026-01-23") - all past doses until this date are dismissed
|
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
});
|
|
|
|
// =============================================================================
|
|
// User Settings - Per user (email, push, thresholds, language)
|
|
// =============================================================================
|
|
export const userSettings = sqliteTable("user_settings", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.unique()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
// Email notifications
|
|
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
|
|
notificationEmail: text("notification_email"),
|
|
emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true),
|
|
emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true),
|
|
// Push notifications (shoutrrr/ntfy)
|
|
shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false),
|
|
shoutrrrUrl: text("shoutrrr_url"),
|
|
shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true),
|
|
shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true),
|
|
// Reminder settings
|
|
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
|
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
|
|
skipRemindersForTakenDoses: integer("skip_reminders_for_taken_doses", { mode: "boolean" }).notNull().default(false),
|
|
repeatRemindersEnabled: integer("repeat_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
|
reminderRepeatIntervalMinutes: integer("reminder_repeat_interval_minutes").notNull().default(30),
|
|
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
|
// Stock thresholds (days)
|
|
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)
|
|
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
|
// Last notification tracking
|
|
lastAutoEmailSent: text("last_auto_email_sent"),
|
|
lastNotificationType: text("last_notification_type"),
|
|
lastNotificationChannel: text("last_notification_channel"),
|
|
lastReminderMedName: text("last_reminder_med_name"),
|
|
lastReminderTakenBy: text("last_reminder_taken_by"),
|
|
// Timestamps
|
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
});
|
|
|
|
// =============================================================================
|
|
// Refresh Tokens - For JWT rotation
|
|
// =============================================================================
|
|
export const refreshTokens = sqliteTable("refresh_tokens", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
tokenId: text("token_id", { length: 255 }).notNull().unique(),
|
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
rotatedAt: integer("rotated_at", { mode: "timestamp" }),
|
|
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
|
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
});
|
|
|
|
// =============================================================================
|
|
// Share Tokens - For public schedule sharing by takenBy person
|
|
// =============================================================================
|
|
export const shareTokens = sqliteTable("share_tokens", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
token: text("token", { length: 64 }).notNull().unique(),
|
|
takenBy: text("taken_by", { length: 100 }).notNull(),
|
|
scheduleDays: integer("schedule_days").notNull().default(30),
|
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
|
});
|
|
|
|
// =============================================================================
|
|
// Dose Tracking - Tracks when doses are marked as taken
|
|
// =============================================================================
|
|
export const doseTracking = sqliteTable("dose_tracking", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
|
});
|
|
|
|
// =============================================================================
|
|
// Refill History - Tracks when medication stock was refilled
|
|
// =============================================================================
|
|
export const refillHistory = sqliteTable("refill_history", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
medicationId: integer("medication_id")
|
|
.notNull()
|
|
.references(() => medications.id, { onDelete: "cascade" }),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
packsAdded: integer("packs_added").notNull().default(0),
|
|
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
|
|
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
|
});
|