diff --git a/.env.example b/.env.example index 68c8b4d..b71a721 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,12 @@ PORT=3000 CORS_ORIGINS=http://localhost:4174 LOG_LEVEL=warn +# Public base URL used for notification action links. +# Required for intake reminder action buttons/links. +# PUBLIC_APP_URL=https://medassist.example.com +# For mobile testing on the same LAN, use your laptop IP instead of localhost, +# e.g. PUBLIC_APP_URL=http://192.168.0.113:5173 and add that origin to CORS_ORIGINS. + # Levels: debug, info, warn, error, silent # Controls: backend Fastify logging, frontend nginx access logs (Docker), # and frontend browser console (via build-time injection) @@ -149,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning # UI defaults # DEFAULT_LANGUAGE=en # en or de # DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual -# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links +# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links # DEFAULT_UPCOMING_TODAY_ONLY=false # DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false \ No newline at end of file diff --git a/backend/drizzle/0015_add_notification_action_tables.sql b/backend/drizzle/0015_add_notification_action_tables.sql new file mode 100644 index 0000000..0c014d5 --- /dev/null +++ b/backend/drizzle/0015_add_notification_action_tables.sql @@ -0,0 +1,30 @@ +CREATE TABLE `notification_action_groups` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `group_key` text(255) NOT NULL, + `sequence_id` text(255) NOT NULL, + `dose_ids_json` text NOT NULL, + `title` text(255) NOT NULL, + `message` text NOT NULL, + `language` text(10) DEFAULT 'en' NOT NULL, + `scheduled_for` integer, + `expires_at` integer NOT NULL, + `resolved_action` text(20), + `resolved_at` integer, + `created_at` integer DEFAULT CURRENT_TIMESTAMP 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 UNIQUE INDEX `notification_action_groups_group_key_unique` ON `notification_action_groups` (`group_key`);--> statement-breakpoint +CREATE TABLE `notification_action_tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `group_id` integer NOT NULL, + `token_hash` text(128) NOT NULL, + `kind` text(20) NOT NULL, + `used_at` integer, + `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`group_id`) REFERENCES `notification_action_groups`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `notification_action_tokens_token_hash_unique` ON `notification_action_tokens` (`token_hash`); \ No newline at end of file diff --git a/backend/drizzle/0016_add_notification_action_group_ntfy_message_id.sql b/backend/drizzle/0016_add_notification_action_group_ntfy_message_id.sql new file mode 100644 index 0000000..5bed984 --- /dev/null +++ b/backend/drizzle/0016_add_notification_action_group_ntfy_message_id.sql @@ -0,0 +1 @@ +ALTER TABLE `notification_action_groups` ADD `ntfy_original_message_id` text(255) DEFAULT '' NOT NULL; \ No newline at end of file diff --git a/backend/src/db/migration-utils.ts b/backend/src/db/migration-utils.ts index 601ed8f..373fc45 100644 --- a/backend/src/db/migration-utils.ts +++ b/backend/src/db/migration-utils.ts @@ -59,6 +59,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`, `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`, `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`, + // Keep the removed legacy setting column for backward compatibility with older SQLite files. `ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`, `ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`, `ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`, @@ -75,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo `ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`, `ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`, `ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`, + `ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`, ]; for (const sql of alterMigrations) { @@ -96,6 +98,31 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo loose_pills_added INTEGER NOT NULL DEFAULT 0, refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now')) )`, + `CREATE TABLE IF NOT EXISTS notification_action_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_key TEXT NOT NULL UNIQUE, + sequence_id TEXT NOT NULL, + ntfy_original_message_id TEXT NOT NULL DEFAULT '', + dose_ids_json TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'en', + scheduled_for INTEGER, + expires_at INTEGER NOT NULL, + resolved_action TEXT, + resolved_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + )`, + `CREATE TABLE IF NOT EXISTS notification_action_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + used_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + )`, `CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -124,6 +151,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo const createIndexMigrations = [ `CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`, `CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`, + `CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`, + `CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`, `CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`, ]; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 99dba58..b2d3ae5 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -108,8 +108,9 @@ export const userSettings = sqliteTable("user_settings", { timezone: text("timezone", { length: 64 }).notNull().default(""), // Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses) stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"), - // Whether shared schedule links show stock status (Critical/Low/Normal) to intake users - shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true), + // Legacy column kept only so existing SQLite files continue to open cleanly after upgrades. + // Current MedAssist versions no longer read or expose this setting in product flows. + legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true), // Whether shared schedule links also embed the medication overview section shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false), // UI timeline visibility preferences @@ -183,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", { expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires }); +// ============================================================================= +// Notification Action Groups - Shared action state for reminder notifications +// ============================================================================= +export const notificationActionGroups = sqliteTable("notification_action_groups", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + groupKey: text("group_key", { length: 255 }).notNull().unique(), + sequenceId: text("sequence_id", { length: 255 }).notNull(), + ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""), + doseIdsJson: text("dose_ids_json").notNull(), + title: text("title", { length: 255 }).notNull(), + message: text("message").notNull(), + language: text("language", { length: 10 }).notNull().default("en"), + scheduledFor: integer("scheduled_for", { mode: "timestamp" }), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + resolvedAction: text("resolved_action", { length: 20 }), + resolvedAt: integer("resolved_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`), +}); + +// ============================================================================= +// Notification Action Tokens - Hashed tokens for public reminder responses +// ============================================================================= +export const notificationActionTokens = sqliteTable("notification_action_tokens", { + id: integer("id").primaryKey({ autoIncrement: true }), + groupId: integer("group_id") + .notNull() + .references(() => notificationActionGroups.id, { onDelete: "cascade" }), + tokenHash: text("token_hash", { length: 128 }).notNull().unique(), + kind: text("kind", { length: 20 }).notNull(), + usedAt: integer("used_at", { mode: "timestamp" }), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), +}); + // ============================================================================= // Dose Tracking - Tracks when doses are marked as taken // ============================================================================= @@ -194,8 +232,8 @@ export const doseTracking = sqliteTable("dose_tracking", { 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 - takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic - dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking + takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification + dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction }); // ============================================================================= diff --git a/backend/src/plugins/env.ts b/backend/src/plugins/env.ts index f723b9d..72c9c1d 100644 --- a/backend/src/plugins/env.ts +++ b/backend/src/plugins/env.ts @@ -10,10 +10,11 @@ const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]).default("production"), PORT: z .string() - .transform((v) => parseInt(v, 10)) - .default(3000), + .default("3000") + .transform((v) => parseInt(v, 10)), CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"), LOG_LEVEL: z.string().default("info"), + PUBLIC_APP_URL: z.string().url().optional(), OPENAPI_DOCS_ENABLED: z .string() .transform((v) => v === "true") @@ -25,18 +26,18 @@ const EnvSchema = z.object({ // Master switch: Enable/disable authentication (default: disabled for easy setup) AUTH_ENABLED: z .string() - .transform((v) => v === "true") - .default(false), + .default("false") + .transform((v) => v === "true"), // Allow new user registrations (auto-enabled if no users exist) REGISTRATION_ENABLED: z .string() - .transform((v) => v === "true") - .default(false), + .default("false") + .transform((v) => v === "true"), // Disable username/password form login (useful for OIDC-only setups) FORM_LOGIN_ENABLED: z .string() - .transform((v) => v === "true") - .default(true), + .default("true") + .transform((v) => v === "true"), // JWT Secrets - only required when AUTH_ENABLED=true JWT_SECRET: z.string().min(10).optional(), @@ -46,20 +47,20 @@ const EnvSchema = z.object({ // Token TTL settings ACCESS_TOKEN_TTL_MINUTES: z .string() - .transform((v) => parseInt(v, 10)) - .default(15), + .default("15") + .transform((v) => parseInt(v, 10)), REFRESH_TOKEN_TTL_DAYS: z .string() - .transform((v) => parseInt(v, 10)) - .default(7), + .default("7") + .transform((v) => parseInt(v, 10)), // ========================================================================== // OIDC SSO Configuration (Pocket ID, Authelia, etc.) // ========================================================================== OIDC_ENABLED: z .string() - .transform((v) => v === "true") - .default(false), + .default("false") + .transform((v) => v === "true"), OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(), @@ -67,8 +68,8 @@ const EnvSchema = z.object({ OIDC_SCOPES: z.string().default("openid profile email"), OIDC_AUTO_CREATE_USERS: z .string() - .transform((v) => v === "true") - .default(true), + .default("true") + .transform((v) => v === "true"), OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub' OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button }); diff --git a/backend/src/test/env.test.ts b/backend/src/test/env.test.ts index ad4f070..cd55eca 100644 --- a/backend/src/test/env.test.ts +++ b/backend/src/test/env.test.ts @@ -10,33 +10,34 @@ const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]).default("production"), PORT: z .string() - .transform((v) => parseInt(v, 10)) - .default(3000), + .default("3000") + .transform((v) => parseInt(v, 10)), CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"), LOG_LEVEL: z.string().default("info"), + PUBLIC_APP_URL: z.string().url().optional(), AUTH_ENABLED: z .string() - .transform((v) => v === "true") - .default(false), + .default("false") + .transform((v) => v === "true"), REGISTRATION_ENABLED: z .string() - .transform((v) => v === "true") - .default(false), + .default("false") + .transform((v) => v === "true"), JWT_SECRET: z.string().min(10).optional(), REFRESH_SECRET: z.string().min(10).optional(), COOKIE_SECRET: z.string().min(10).optional(), ACCESS_TOKEN_TTL_MINUTES: z .string() - .transform((v) => parseInt(v, 10)) - .default(15), + .default("15") + .transform((v) => parseInt(v, 10)), REFRESH_TOKEN_TTL_DAYS: z .string() - .transform((v) => parseInt(v, 10)) - .default(7), + .default("7") + .transform((v) => parseInt(v, 10)), OIDC_ENABLED: z .string() - .transform((v) => v === "true") - .default(false), + .default("false") + .transform((v) => v === "true"), OIDC_ISSUER_URL: z.string().url().optional(), OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(), @@ -44,8 +45,8 @@ const EnvSchema = z.object({ OIDC_SCOPES: z.string().default("openid profile email"), OIDC_AUTO_CREATE_USERS: z .string() - .transform((v) => v === "true") - .default(true), + .default("true") + .transform((v) => v === "true"), OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), OIDC_PROVIDER_NAME: z.string().default("SSO"), }); @@ -81,6 +82,7 @@ describe("EnvSchema", () => { expect(result.PORT).toBe(3000); expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173"); expect(result.LOG_LEVEL).toBe("info"); + expect(result.PUBLIC_APP_URL).toBeUndefined(); expect(result.AUTH_ENABLED).toBe(false); expect(result.REGISTRATION_ENABLED).toBe(false); expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15); @@ -188,6 +190,15 @@ describe("EnvSchema", () => { }); describe("OIDC URL validation", () => { + it("should accept valid PUBLIC_APP_URL", () => { + const result = EnvSchema.parse({ PUBLIC_APP_URL: "https://medassist.example.com" }); + expect(result.PUBLIC_APP_URL).toBe("https://medassist.example.com"); + }); + + it("should reject invalid PUBLIC_APP_URL", () => { + expect(() => EnvSchema.parse({ PUBLIC_APP_URL: "not-a-url" })).toThrow(); + }); + it("should accept valid OIDC_ISSUER_URL", () => { const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" }); expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");